diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..1230156 --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,342 @@ +use anyhow::Result; +use clap::Arg; +use clap::ArgAction; +use clap::CommandFactory; +use clap::FromArgMatches; +use clap::Parser; +use clap::Subcommand; +use std::ffi::OsStr; +use std::path::PathBuf; + +use crate::codex_config::is_valid_profile_name; +use crate::locale::Locale; +use crate::output::*; + +pub(crate) const APP_VERSION: &str = env!("CARGO_PKG_VERSION"); +pub(crate) const DEFAULT_POLL_INTERVAL_MS: u64 = 500; +pub(crate) const DEFAULT_BUCKET_PADDING_BYTES: usize = 256; + +#[derive(Parser, Debug)] +#[command( + author, + version, + about = "placeholder", + long_about = "placeholder", + disable_help_flag = true, + disable_version_flag = true, + disable_help_subcommand = true +)] +pub(crate) struct Cli { + #[arg(long, global = true, value_name = "DIR", help = "placeholder")] + pub(crate) codex_home: Option, + + #[arg(long, global = true, value_name = "PROVIDER", help = "placeholder")] + pub(crate) provider: Option, + + #[arg(long, global = true, value_name = "PROFILE", help = "placeholder")] + pub(crate) profile: Option, + + #[command(subcommand)] + pub(crate) command: Command, +} + +#[derive(Subcommand, Debug)] +pub(crate) enum Command { + /// placeholder + Status, + /// placeholder + Sync { + #[arg(long, help = "placeholder")] + sqlite_only: bool, + }, + /// placeholder + Bucket { + #[command(subcommand)] + command: BucketCommand, + }, + /// placeholder + #[command(alias = "daemon")] + Watch { + #[arg( + long, + default_value_t = DEFAULT_POLL_INTERVAL_MS, + value_name = "MILLISECONDS", + help = "placeholder" + )] + poll_interval_ms: u64, + #[arg(long, help = "placeholder")] + sqlite_only: bool, + }, + /// placeholder + #[command(name = "print-service-config", alias = "print-plist")] + PrintServiceConfig { + #[arg( + long, + default_value_t = DEFAULT_POLL_INTERVAL_MS, + value_name = "MILLISECONDS", + help = "placeholder" + )] + poll_interval_ms: u64, + }, + /// placeholder + #[command(name = "install-service", alias = "install-launchd")] + InstallService { + #[arg( + long, + default_value_t = DEFAULT_POLL_INTERVAL_MS, + value_name = "MILLISECONDS", + help = "placeholder" + )] + poll_interval_ms: u64, + }, + /// placeholder + #[command(name = "uninstall-service", alias = "uninstall-launchd")] + UninstallService, +} + +#[derive(Subcommand, Debug)] +pub(crate) enum BucketCommand { + /// placeholder + Prepare { + #[arg( + long, + default_value_t = DEFAULT_BUCKET_PADDING_BYTES, + value_name = "BYTES", + help = "placeholder" + )] + padding_bytes: usize, + }, + /// placeholder + Switch { + #[arg(value_name = "PROVIDER")] + target_provider: Option, + #[arg( + long, + default_value_t = DEFAULT_BUCKET_PADDING_BYTES, + value_name = "BYTES", + help = "placeholder" + )] + padding_bytes: usize, + }, +} + +pub(crate) fn localized_command(locale: Locale) -> clap::Command { + let mut command = Cli::command(); + command = command + .version(APP_VERSION) + .disable_help_flag(true) + .disable_version_flag(true) + .disable_help_subcommand(true) + .about(root_about(locale)) + .long_about(root_long_about(locale)) + .help_template(help_template(locale)) + .subcommand_help_heading(commands_heading(locale)) + .arg( + Arg::new("help") + .short('h') + .long("help") + .global(true) + .action(ArgAction::Help) + .help(help_option_help(locale)) + .help_heading(options_heading(locale)), + ) + .arg( + Arg::new("version") + .short('V') + .long("version") + .global(true) + .action(ArgAction::Version) + .help(version_option_help(locale)) + .help_heading(options_heading(locale)), + ); + command = command.mut_arg("codex_home", |arg| { + arg.help(codex_home_help(locale)) + .help_heading(options_heading(locale)) + .value_name(dir_value_name(locale)) + }); + command = command.mut_arg("provider", |arg| { + arg.help(provider_help(locale)) + .help_heading(options_heading(locale)) + .value_name(provider_value_name(locale)) + }); + command = command.mut_arg("profile", |arg| { + arg.help(profile_help(locale)) + .help_heading(options_heading(locale)) + .value_name(profile_value_name(locale)) + }); + + command = command.mut_subcommand("status", |sub| { + sub.about(status_about(locale)) + .version(APP_VERSION) + .disable_help_flag(true) + .disable_version_flag(true) + .disable_help_subcommand(true) + .help_template(help_template(locale)) + }); + command = command.mut_subcommand("sync", |sub| { + sub.about(sync_about(locale)) + .version(APP_VERSION) + .disable_help_flag(true) + .disable_version_flag(true) + .disable_help_subcommand(true) + .help_template(help_template(locale)) + .mut_arg("sqlite_only", |arg| { + arg.help(sqlite_only_help(locale)) + .help_heading(options_heading(locale)) + }) + }); + command = command.mut_subcommand("bucket", |sub| { + sub.about(bucket_about(locale)) + .version(APP_VERSION) + .disable_help_flag(true) + .disable_version_flag(true) + .disable_help_subcommand(true) + .help_template(help_template(locale)) + .mut_subcommand("prepare", |sub| { + sub.about(bucket_prepare_about(locale)) + .version(APP_VERSION) + .disable_help_flag(true) + .disable_version_flag(true) + .disable_help_subcommand(true) + .help_template(help_template(locale)) + .mut_arg("padding_bytes", |arg| { + arg.help(padding_bytes_help(locale)) + .help_heading(options_heading(locale)) + .value_name(bytes_value_name(locale)) + }) + }) + .mut_subcommand("switch", |sub| { + sub.about(bucket_switch_about(locale)) + .version(APP_VERSION) + .disable_help_flag(true) + .disable_version_flag(true) + .disable_help_subcommand(true) + .help_template(help_template(locale)) + .mut_arg("target_provider", |arg| { + arg.help(bucket_switch_provider_help(locale)) + .value_name(provider_value_name(locale)) + }) + .mut_arg("padding_bytes", |arg| { + arg.help(padding_bytes_help(locale)) + .help_heading(options_heading(locale)) + .value_name(bytes_value_name(locale)) + }) + }) + }); + command = command.mut_subcommand("watch", |sub| { + sub.about(watch_about(locale)) + .version(APP_VERSION) + .disable_help_flag(true) + .disable_version_flag(true) + .disable_help_subcommand(true) + .help_template(help_template(locale)) + .mut_arg("sqlite_only", |arg| { + arg.help(sqlite_only_help(locale)) + .help_heading(options_heading(locale)) + }) + .mut_arg("poll_interval_ms", |arg| { + arg.help(poll_interval_help(locale)) + .help_heading(options_heading(locale)) + .value_name(milliseconds_value_name(locale)) + }) + }); + command = command.mut_subcommand("print-service-config", |sub| { + sub.about(print_plist_about(locale)) + .version(APP_VERSION) + .disable_help_flag(true) + .disable_version_flag(true) + .disable_help_subcommand(true) + .help_template(help_template(locale)) + .mut_arg("poll_interval_ms", |arg| { + arg.help(launchd_poll_help(locale)) + .help_heading(options_heading(locale)) + .value_name(milliseconds_value_name(locale)) + }) + }); + command = command.mut_subcommand("install-service", |sub| { + sub.about(install_launchd_about(locale)) + .version(APP_VERSION) + .disable_help_flag(true) + .disable_version_flag(true) + .disable_help_subcommand(true) + .help_template(help_template(locale)) + .mut_arg("poll_interval_ms", |arg| { + arg.help(launchd_poll_help(locale)) + .help_heading(options_heading(locale)) + .value_name(milliseconds_value_name(locale)) + }) + }); + command = command.mut_subcommand("uninstall-service", |sub| { + sub.about(uninstall_launchd_about(locale)) + .version(APP_VERSION) + .disable_help_flag(true) + .disable_version_flag(true) + .disable_help_subcommand(true) + .help_template(help_template(locale)) + }); + + command +} + +pub(crate) fn parse_cli(locale: Locale) -> Result { + validate_provider_override_args(locale, std::env::args_os())?; + let command = localized_command(locale); + let matches = command.clone().get_matches(); + Cli::from_arg_matches(&matches).map_err(|err| anyhow::anyhow!(err.to_string())) +} + +pub(crate) fn validate_provider_override(locale: Locale, provider: Option<&str>) -> Result<()> { + if provider.is_some_and(|value| value.trim().is_empty()) { + anyhow::bail!(provider_empty_error(locale)); + } + Ok(()) +} + +pub(crate) fn validate_profile_override(locale: Locale, profile: Option<&str>) -> Result<()> { + let Some(profile) = profile else { + return Ok(()); + }; + let trimmed = profile.trim(); + if trimmed.is_empty() { + anyhow::bail!(profile_empty_error(locale)); + } + if !is_valid_profile_name(trimmed) { + anyhow::bail!(profile_path_error(locale)); + } + Ok(()) +} + +pub(crate) fn validate_provider_override_args(locale: Locale, args: I) -> Result<()> +where + I: IntoIterator, + T: AsRef, +{ + let mut args = args.into_iter().skip(1); + while let Some(arg) = args.next() { + let arg = arg.as_ref(); + if arg == OsStr::new("--") { + break; + } + if arg == OsStr::new("--provider") { + if let Some(value) = args.next() { + validate_provider_override(locale, value.as_ref().to_str())?; + } + continue; + } + if arg == OsStr::new("--profile") { + if let Some(value) = args.next() { + validate_profile_override(locale, value.as_ref().to_str())?; + } + continue; + } + + let rendered = arg.to_string_lossy(); + if let Some(value) = rendered.strip_prefix("--provider=") { + validate_provider_override(locale, Some(value))?; + } + if let Some(value) = rendered.strip_prefix("--profile=") { + validate_profile_override(locale, Some(value))?; + } + } + Ok(()) +} diff --git a/src/codex_config.rs b/src/codex_config.rs new file mode 100644 index 0000000..cc258cf --- /dev/null +++ b/src/codex_config.rs @@ -0,0 +1,203 @@ +use anyhow::Context; +use anyhow::Result; +use serde::Deserialize; +use std::collections::BTreeMap; +use std::path::Path; +use std::path::PathBuf; + +pub(crate) const DEFAULT_PROVIDER: &str = "openai"; +pub(crate) const STATE_DB_FILENAME: &str = "state_5.sqlite"; + +const CODEX_SQLITE_HOME_ENV: &str = "CODEX_SQLITE_HOME"; +const PROFILE_CONFIG_SUFFIX: &str = ".config.toml"; + +#[derive(Debug, Default, Deserialize)] +struct ConfigToml { + model_provider: Option, + sqlite_home: Option, + profile: Option, + profiles: Option>, +} + +#[derive(Debug, Deserialize)] +struct ConfigProfileToml { + model_provider: Option, +} + +impl ConfigToml { + fn overlay(&mut self, profile_config: ConfigToml) { + if profile_config.model_provider.is_some() { + self.model_provider = profile_config.model_provider; + } + if profile_config.sqlite_home.is_some() { + self.sqlite_home = profile_config.sqlite_home; + } + } +} + +pub(crate) fn is_valid_profile_name(profile: &str) -> bool { + profile + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) +} + +pub(crate) fn read_provider_from_config( + codex_home: &Path, + profile_override: Option<&str>, +) -> Result { + let parsed = read_effective_codex_config(codex_home, profile_override)?; + Ok(parsed + .model_provider + .filter(|s| !s.trim().is_empty()) + .unwrap_or_else(|| DEFAULT_PROVIDER.to_string())) +} + +pub(crate) fn resolve_sqlite_path( + codex_home: &Path, + profile_override: Option<&str>, +) -> Result { + let parsed = read_effective_codex_config(codex_home, profile_override)?; + let current_dir = + std::env::current_dir().context("failed to resolve current directory for sqlite_home")?; + Ok(resolve_sqlite_home_from_config( + codex_home, + parsed.sqlite_home.as_deref(), + std::env::var(CODEX_SQLITE_HOME_ENV).ok().as_deref(), + current_dir.as_path(), + ) + .join(STATE_DB_FILENAME)) +} + +fn read_effective_codex_config( + codex_home: &Path, + profile_override: Option<&str>, +) -> Result { + let mut config = read_codex_config(codex_home)?; + let explicit_profile = profile_override.and_then(trimmed_string); + let config_profile = config.profile.as_deref().and_then(trimmed_string); + + if let Some(profile) = explicit_profile { + apply_profile_config(codex_home, &mut config, profile.as_str(), true)?; + } else if let Some(profile) = config_profile { + apply_profile_config(codex_home, &mut config, profile.as_str(), false)?; + } + + Ok(config) +} + +fn apply_profile_config( + codex_home: &Path, + config: &mut ConfigToml, + profile: &str, + required: bool, +) -> Result<()> { + if !is_valid_profile_name(profile) { + anyhow::bail!("profile `{profile}` is not a valid Codex profile name"); + } + if let Some(profile_config) = read_profile_v2_config(codex_home, profile)? { + config.overlay(profile_config); + return Ok(()); + } + if let Some(profile_config) = config + .profiles + .as_ref() + .and_then(|profiles| profiles.get(profile)) + { + if let Some(provider) = profile_config.model_provider.as_ref() { + config.model_provider = Some(provider.clone()); + } + return Ok(()); + } + if required { + anyhow::bail!( + "profile `{}` was not found; expected {} or [profiles.{}] in {}", + profile, + profile_config_path(codex_home, profile).display(), + profile, + codex_home.join("config.toml").display() + ); + } + Ok(()) +} + +fn read_codex_config(codex_home: &Path) -> Result { + read_optional_config_file(codex_home.join("config.toml").as_path()) + .map(|config| config.unwrap_or_default()) +} + +fn read_profile_v2_config(codex_home: &Path, profile: &str) -> Result> { + let path = profile_config_path(codex_home, profile); + read_optional_config_file(path.as_path()) +} + +fn read_optional_config_file(path: &Path) -> Result> { + let raw = match std::fs::read_to_string(path) { + Ok(raw) => raw, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(err) => return Err(err).with_context(|| format!("failed to read {}", path.display())), + }; + toml::from_str(&raw) + .map(Some) + .with_context(|| format!("failed to parse {}", path.display())) +} + +pub(crate) fn selected_profile_config_path( + codex_home: &Path, + profile_override: Option<&str>, +) -> Option { + let profile = profile_override.and_then(trimmed_string).or_else(|| { + read_codex_config(codex_home) + .ok() + .and_then(|config| config.profile.as_deref().and_then(trimmed_string)) + })?; + if is_valid_profile_name(profile.as_str()) { + Some(profile_config_path(codex_home, profile.as_str())) + } else { + None + } +} + +fn profile_config_path(codex_home: &Path, profile: &str) -> PathBuf { + codex_home.join(format!("{profile}{PROFILE_CONFIG_SUFFIX}")) +} + +fn trimmed_string(value: &str) -> Option { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } +} + +pub(crate) fn resolve_sqlite_home_from_config( + codex_home: &Path, + config_sqlite_home: Option<&str>, + env_sqlite_home: Option<&str>, + current_dir: &Path, +) -> PathBuf { + if let Some(path) = config_sqlite_home.and_then(trimmed_path) { + return resolve_path_relative_to(path, codex_home); + } + if let Some(path) = env_sqlite_home.and_then(trimmed_path) { + return resolve_path_relative_to(path, current_dir); + } + codex_home.to_path_buf() +} + +fn trimmed_path(value: &str) -> Option<&Path> { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(Path::new(trimmed)) + } +} + +fn resolve_path_relative_to(path: &Path, base: &Path) -> PathBuf { + if path.is_absolute() { + path.to_path_buf() + } else { + base.join(path) + } +} diff --git a/src/fs_sync.rs b/src/fs_sync.rs new file mode 100644 index 0000000..736fbe2 --- /dev/null +++ b/src/fs_sync.rs @@ -0,0 +1,30 @@ +use anyhow::Context; +use anyhow::Result; +#[cfg(unix)] +use std::fs::File; +use std::fs::OpenOptions; +use std::path::Path; + +pub(crate) fn sync_file(path: &Path) -> Result<()> { + let file = OpenOptions::new() + .read(true) + .write(true) + .open(path) + .with_context(|| format!("open for sync: {}", path.display()))?; + file.sync_all() + .with_context(|| format!("fsync: {}", path.display()))?; + Ok(()) +} + +#[cfg(unix)] +pub(crate) fn sync_dir(path: &Path) -> Result<()> { + let dir = File::open(path).with_context(|| format!("open for sync: {}", path.display()))?; + dir.sync_all() + .with_context(|| format!("fsync: {}", path.display()))?; + Ok(()) +} + +#[cfg(not(unix))] +pub(crate) fn sync_dir(_path: &Path) -> Result<()> { + Ok(()) +} diff --git a/src/locale.rs b/src/locale.rs new file mode 100644 index 0000000..448f814 --- /dev/null +++ b/src/locale.rs @@ -0,0 +1,78 @@ +use std::process::Command as ProcessCommand; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum Locale { + En, + ZhHans, +} + +pub(crate) fn detect_locale() -> Locale { + let mut candidates = Vec::new(); + if let Ok(value) = std::env::var("CODEX_THREADRIPPER_LANG") { + candidates.push(value); + } + for key in ["LC_ALL", "LC_MESSAGES", "LANG"] { + if let Ok(value) = std::env::var(key) { + candidates.push(value); + } + } + + detect_locale_from_sources( + candidates.iter().map(String::as_str), + apple_languages_output().as_deref(), + ) +} + +pub(crate) fn detect_locale_from_sources<'a, I>( + candidates: I, + apple_languages: Option<&str>, +) -> Locale +where + I: IntoIterator, +{ + for candidate in candidates { + if let Some(locale) = parse_locale_tag(candidate) { + return locale; + } + } + if let Some(output) = apple_languages + && let Some(locale) = parse_apple_languages(output) + { + return locale; + } + Locale::En +} + +pub(crate) fn parse_locale_tag(input: &str) -> Option { + let normalized = input.trim().to_ascii_lowercase(); + if normalized.is_empty() { + return None; + } + if normalized.starts_with("zh") { + return Some(Locale::ZhHans); + } + if normalized.starts_with("en") { + return Some(Locale::En); + } + None +} + +pub(crate) fn parse_apple_languages(output: &str) -> Option { + output + .split(|ch: char| !(ch.is_ascii_alphanumeric() || ch == '-' || ch == '_')) + .find_map(parse_locale_tag) +} + +fn apple_languages_output() -> Option { + if !cfg!(target_os = "macos") { + return None; + } + let output = ProcessCommand::new("defaults") + .args(["read", "-g", "AppleLanguages"]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + String::from_utf8(output.stdout).ok() +} diff --git a/src/main.rs b/src/main.rs index 080357e..224b542 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,369 +1,50 @@ use anyhow::Context; use anyhow::Result; -use clap::Arg; -use clap::ArgAction; -use clap::CommandFactory; -use clap::FromArgMatches; -use clap::Parser; -use clap::Subcommand; -use filetime::FileTime; -use filetime::set_file_times; -use fs2::FileExt; -use notify::Config as NotifyConfig; -use notify::EventKind; -use notify::RecommendedWatcher; -use notify::RecursiveMode; -use notify::Watcher; -use rusqlite::Connection; -use rusqlite::TransactionBehavior; -use rusqlite::backup::Backup; -use rusqlite::backup::StepResult; -use serde::Deserialize; -use serde_json::Value; -use serde_json::json; -use std::collections::BTreeMap; -use std::ffi::OsStr; -use std::fs; -use std::fs::File; -use std::fs::OpenOptions; -use std::io::BufRead; -use std::io::BufReader; -use std::io::BufWriter; -use std::io::IsTerminal; -use std::io::Seek; -use std::io::SeekFrom; -use std::io::Write; use std::path::Path; use std::path::PathBuf; -use std::process::Command as ProcessCommand; -use std::sync::Arc; -use std::sync::atomic::AtomicBool; -use std::sync::atomic::Ordering; -use std::sync::mpsc; use std::time::Duration; -use std::time::Instant; -use std::time::SystemTime; -use std::time::UNIX_EPOCH; - -const APP_VERSION: &str = env!("CARGO_PKG_VERSION"); -const DEFAULT_PROVIDER: &str = "openai"; -const CODEX_SQLITE_HOME_ENV: &str = "CODEX_SQLITE_HOME"; -const STATE_DB_FILENAME: &str = "state_5.sqlite"; -const PROFILE_CONFIG_SUFFIX: &str = ".config.toml"; -const DEFAULT_POLL_INTERVAL_MS: u64 = 500; -const DEFAULT_BUCKET_PADDING_BYTES: usize = 256; -const ROLLOUT_PROGRESS_INTERVAL: Duration = Duration::from_millis(500); -const WATCH_FULL_ROLLOUT_POLL_INTERVALS: u64 = 120; - -type ProviderDistribution = Vec<(String, u64)>; +mod cli; +mod codex_config; +mod fs_sync; +mod locale; +mod output; +mod rollout; mod service; - -use service::ServiceInstallSummary; -use service::ServiceManager; -use service::ServiceStatus as BackgroundServiceStatus; - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum Locale { - En, - ZhHans, -} - -#[derive(Parser, Debug)] -#[command( - author, - version, - about = "placeholder", - long_about = "placeholder", - disable_help_flag = true, - disable_version_flag = true, - disable_help_subcommand = true -)] -struct Cli { - #[arg(long, global = true, value_name = "DIR", help = "placeholder")] - codex_home: Option, - - #[arg(long, global = true, value_name = "PROVIDER", help = "placeholder")] - provider: Option, - - #[arg(long, global = true, value_name = "PROFILE", help = "placeholder")] - profile: Option, - - #[command(subcommand)] - command: Command, -} - -#[derive(Subcommand, Debug)] -enum Command { - /// placeholder - Status, - /// placeholder - Sync { - #[arg(long, help = "placeholder")] - sqlite_only: bool, - }, - /// placeholder - Bucket { - #[command(subcommand)] - command: BucketCommand, - }, - /// placeholder - #[command(alias = "daemon")] - Watch { - #[arg( - long, - default_value_t = DEFAULT_POLL_INTERVAL_MS, - value_name = "MILLISECONDS", - help = "placeholder" - )] - poll_interval_ms: u64, - #[arg(long, help = "placeholder")] - sqlite_only: bool, - }, - /// placeholder - #[command(name = "print-service-config", alias = "print-plist")] - PrintServiceConfig { - #[arg( - long, - default_value_t = DEFAULT_POLL_INTERVAL_MS, - value_name = "MILLISECONDS", - help = "placeholder" - )] - poll_interval_ms: u64, - }, - /// placeholder - #[command(name = "install-service", alias = "install-launchd")] - InstallService { - #[arg( - long, - default_value_t = DEFAULT_POLL_INTERVAL_MS, - value_name = "MILLISECONDS", - help = "placeholder" - )] - poll_interval_ms: u64, - }, - /// placeholder - #[command(name = "uninstall-service", alias = "uninstall-launchd")] - UninstallService, -} - -#[derive(Subcommand, Debug)] -enum BucketCommand { - /// placeholder - Prepare { - #[arg( - long, - default_value_t = DEFAULT_BUCKET_PADDING_BYTES, - value_name = "BYTES", - help = "placeholder" - )] - padding_bytes: usize, - }, - /// placeholder - Switch { - #[arg(value_name = "PROVIDER")] - target_provider: Option, - #[arg( - long, - default_value_t = DEFAULT_BUCKET_PADDING_BYTES, - value_name = "BYTES", - help = "placeholder" - )] - padding_bytes: usize, - }, -} - -#[derive(Debug, Default, Deserialize)] -struct ConfigToml { - model_provider: Option, - sqlite_home: Option, - profile: Option, - profiles: Option>, -} - -#[derive(Debug, Deserialize)] -struct ConfigProfileToml { - model_provider: Option, -} - -impl ConfigToml { - fn overlay(&mut self, profile_config: ConfigToml) { - if profile_config.model_provider.is_some() { - self.model_provider = profile_config.model_provider; - } - if profile_config.sqlite_home.is_some() { - self.sqlite_home = profile_config.sqlite_home; - } - } -} - -#[derive(Debug)] -struct ReconcileSummary { - provider: String, - changed_rows: u64, - total_rows: u64, - changed_rollouts: u64, - checked_rollouts: u64, - prepared_rollouts: u64, - skipped_rollouts: u64, - elapsed: Duration, - backup_path: Option, - rollout_journal_path: Option, -} - -#[derive(Debug)] -struct StatusSummary { - codex_home: PathBuf, - sqlite_path: PathBuf, - config_path: PathBuf, - provider: String, - total_rows: u64, - mismatched_rows: u64, - distribution: ProviderDistribution, - service_status: BackgroundServiceStatus, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum RolloutScope { - None, - MismatchedRows, - AllRows, -} - -#[derive(Debug)] -struct RolloutTarget { - thread_id: String, - path: PathBuf, -} - -#[derive(Debug, Default)] -struct RolloutReconcileSummary { - checked_files: u64, - changed_files: u64, - prepared_files: u64, - skipped_files: u64, - journal_path: Option, -} - -#[derive(Debug)] -struct BucketPrepareSummary { - checked_rollouts: u64, - prepared_rollouts: u64, - skipped_rollouts: u64, - elapsed: Duration, - journal_path: Option, -} - -#[derive(Clone, Copy, Debug)] -struct RolloutProgressConfig { - locale: Locale, -} - -struct RolloutProgress { - config: RolloutProgressConfig, - total_files: u64, - visited_files: u64, - started_at: Instant, - last_print_at: Instant, - is_terminal: bool, - printed: bool, -} - -#[derive(Clone, Copy, Debug)] -struct RolloutProgressSnapshot { - visited_files: u64, - total_files: u64, - checked_files: u64, - changed_files: u64, - prepared_files: u64, - skipped_files: u64, - elapsed: Duration, -} - -#[derive(Debug)] -struct FirstLine { - content: Vec, - newline: Vec, -} - -impl RolloutProgress { - fn new(config: RolloutProgressConfig, total_files: u64) -> Self { - let now = Instant::now(); - Self { - config, - total_files, - visited_files: 0, - started_at: now, - last_print_at: now, - is_terminal: std::io::stderr().is_terminal(), - printed: false, - } - } - - fn tick(&mut self, summary: &RolloutReconcileSummary) { - if self.total_files == 0 { - return; - } - self.visited_files += 1; - let now = Instant::now(); - let should_print = self.visited_files == 1 - || self.visited_files == self.total_files - || now.duration_since(self.last_print_at) >= ROLLOUT_PROGRESS_INTERVAL; - if should_print { - self.print(summary, false); - self.last_print_at = now; - } - } - - fn finish(&mut self, summary: &RolloutReconcileSummary) { - if self.total_files == 0 { - return; - } - self.print(summary, true); - } - - fn print(&mut self, summary: &RolloutReconcileSummary, final_line: bool) { - let snapshot = RolloutProgressSnapshot { - visited_files: self.visited_files, - total_files: self.total_files, - checked_files: summary.checked_files, - changed_files: summary.changed_files, - prepared_files: summary.prepared_files, - skipped_files: summary.skipped_files, - elapsed: self.started_at.elapsed(), - }; - let message = rollout_progress_message(self.config.locale, &snapshot); - let mut stderr = std::io::stderr(); - if self.is_terminal { - let _ = write!(stderr, "\r{message}\x1b[K"); - if final_line { - let _ = writeln!(stderr); - } - } else if final_line || !self.printed { - let _ = writeln!(stderr, "{message}"); - } - let _ = stderr.flush(); - self.printed = true; - } -} - -#[derive(Debug, Default)] -struct RolloutPatchOutcome { - changed: bool, - prepared: bool, - skipped: bool, -} - -struct RolloutChangeJournal { - path: PathBuf, - writer: Option>, -} - -#[derive(Clone, Copy)] -enum RolloutChangeMode { - InPlace, - RewriteWithPadding, -} +mod state_db; +mod sync; +#[cfg(test)] +mod tests; +mod watch; + +use cli::BucketCommand; +use cli::Command; +use cli::parse_cli; +use cli::validate_profile_override; +use cli::validate_provider_override; +use locale::Locale; +use locale::detect_locale; +use output::bucket_switch_complete_title; +use output::cli_status_command; +use output::current_exe_error; +use output::install_next_steps; +use output::launchd_plist_message; +use output::next_steps_heading; +use output::no_launchd_plist_message; +use output::print_bucket_prepare_summary; +use output::print_install_service_summary; +use output::print_status; +use output::print_sync_summary; +use output::run_status_next_step; +use output::sync_complete_title; +use output::uninstall_launchd_done; +use rollout::RolloutProgressConfig; +use rollout::RolloutScope; +use rollout::prepare_bucket_padding; +use sync::collect_status; +use sync::reconcile_once_with_backup_and_padding; +use sync::reconcile_once_with_backup_progress; +use watch::run_watch; fn main() -> Result<()> { let locale = detect_locale(); @@ -469,301 +150,6 @@ fn main() -> Result<()> { Ok(()) } -fn parse_cli(locale: Locale) -> Result { - validate_provider_override_args(locale, std::env::args_os())?; - let command = localized_command(locale); - let matches = command.clone().get_matches(); - Cli::from_arg_matches(&matches).map_err(|err| anyhow::anyhow!(err.to_string())) -} - -fn validate_provider_override(locale: Locale, provider: Option<&str>) -> Result<()> { - if provider.is_some_and(|value| value.trim().is_empty()) { - anyhow::bail!(provider_empty_error(locale)); - } - Ok(()) -} - -fn validate_profile_override(locale: Locale, profile: Option<&str>) -> Result<()> { - let Some(profile) = profile else { - return Ok(()); - }; - let trimmed = profile.trim(); - if trimmed.is_empty() { - anyhow::bail!(profile_empty_error(locale)); - } - if !is_valid_profile_name(trimmed) { - anyhow::bail!(profile_path_error(locale)); - } - Ok(()) -} - -fn is_valid_profile_name(profile: &str) -> bool { - profile - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) -} - -fn validate_provider_override_args(locale: Locale, args: I) -> Result<()> -where - I: IntoIterator, - T: AsRef, -{ - let mut args = args.into_iter().skip(1); - while let Some(arg) = args.next() { - let arg = arg.as_ref(); - if arg == OsStr::new("--") { - break; - } - if arg == OsStr::new("--provider") { - if let Some(value) = args.next() { - validate_provider_override(locale, value.as_ref().to_str())?; - } - continue; - } - if arg == OsStr::new("--profile") { - if let Some(value) = args.next() { - validate_profile_override(locale, value.as_ref().to_str())?; - } - continue; - } - - let rendered = arg.to_string_lossy(); - if let Some(value) = rendered.strip_prefix("--provider=") { - validate_provider_override(locale, Some(value))?; - } - if let Some(value) = rendered.strip_prefix("--profile=") { - validate_profile_override(locale, Some(value))?; - } - } - Ok(()) -} - -fn localized_command(locale: Locale) -> clap::Command { - let mut command = Cli::command(); - command = command - .version(APP_VERSION) - .disable_help_flag(true) - .disable_version_flag(true) - .disable_help_subcommand(true) - .about(root_about(locale)) - .long_about(root_long_about(locale)) - .help_template(help_template(locale)) - .subcommand_help_heading(commands_heading(locale)) - .arg( - Arg::new("help") - .short('h') - .long("help") - .global(true) - .action(ArgAction::Help) - .help(help_option_help(locale)) - .help_heading(options_heading(locale)), - ) - .arg( - Arg::new("version") - .short('V') - .long("version") - .global(true) - .action(ArgAction::Version) - .help(version_option_help(locale)) - .help_heading(options_heading(locale)), - ); - command = command.mut_arg("codex_home", |arg| { - arg.help(codex_home_help(locale)) - .help_heading(options_heading(locale)) - .value_name(dir_value_name(locale)) - }); - command = command.mut_arg("provider", |arg| { - arg.help(provider_help(locale)) - .help_heading(options_heading(locale)) - .value_name(provider_value_name(locale)) - }); - command = command.mut_arg("profile", |arg| { - arg.help(profile_help(locale)) - .help_heading(options_heading(locale)) - .value_name(profile_value_name(locale)) - }); - - command = command.mut_subcommand("status", |sub| { - sub.about(status_about(locale)) - .version(APP_VERSION) - .disable_help_flag(true) - .disable_version_flag(true) - .disable_help_subcommand(true) - .help_template(help_template(locale)) - }); - command = command.mut_subcommand("sync", |sub| { - sub.about(sync_about(locale)) - .version(APP_VERSION) - .disable_help_flag(true) - .disable_version_flag(true) - .disable_help_subcommand(true) - .help_template(help_template(locale)) - .mut_arg("sqlite_only", |arg| { - arg.help(sqlite_only_help(locale)) - .help_heading(options_heading(locale)) - }) - }); - command = command.mut_subcommand("bucket", |sub| { - sub.about(bucket_about(locale)) - .version(APP_VERSION) - .disable_help_flag(true) - .disable_version_flag(true) - .disable_help_subcommand(true) - .help_template(help_template(locale)) - .mut_subcommand("prepare", |sub| { - sub.about(bucket_prepare_about(locale)) - .version(APP_VERSION) - .disable_help_flag(true) - .disable_version_flag(true) - .disable_help_subcommand(true) - .help_template(help_template(locale)) - .mut_arg("padding_bytes", |arg| { - arg.help(padding_bytes_help(locale)) - .help_heading(options_heading(locale)) - .value_name(bytes_value_name(locale)) - }) - }) - .mut_subcommand("switch", |sub| { - sub.about(bucket_switch_about(locale)) - .version(APP_VERSION) - .disable_help_flag(true) - .disable_version_flag(true) - .disable_help_subcommand(true) - .help_template(help_template(locale)) - .mut_arg("target_provider", |arg| { - arg.help(bucket_switch_provider_help(locale)) - .value_name(provider_value_name(locale)) - }) - .mut_arg("padding_bytes", |arg| { - arg.help(padding_bytes_help(locale)) - .help_heading(options_heading(locale)) - .value_name(bytes_value_name(locale)) - }) - }) - }); - command = command.mut_subcommand("watch", |sub| { - sub.about(watch_about(locale)) - .version(APP_VERSION) - .disable_help_flag(true) - .disable_version_flag(true) - .disable_help_subcommand(true) - .help_template(help_template(locale)) - .mut_arg("sqlite_only", |arg| { - arg.help(sqlite_only_help(locale)) - .help_heading(options_heading(locale)) - }) - .mut_arg("poll_interval_ms", |arg| { - arg.help(poll_interval_help(locale)) - .help_heading(options_heading(locale)) - .value_name(milliseconds_value_name(locale)) - }) - }); - command = command.mut_subcommand("print-service-config", |sub| { - sub.about(print_plist_about(locale)) - .version(APP_VERSION) - .disable_help_flag(true) - .disable_version_flag(true) - .disable_help_subcommand(true) - .help_template(help_template(locale)) - .mut_arg("poll_interval_ms", |arg| { - arg.help(launchd_poll_help(locale)) - .help_heading(options_heading(locale)) - .value_name(milliseconds_value_name(locale)) - }) - }); - command = command.mut_subcommand("install-service", |sub| { - sub.about(install_launchd_about(locale)) - .version(APP_VERSION) - .disable_help_flag(true) - .disable_version_flag(true) - .disable_help_subcommand(true) - .help_template(help_template(locale)) - .mut_arg("poll_interval_ms", |arg| { - arg.help(launchd_poll_help(locale)) - .help_heading(options_heading(locale)) - .value_name(milliseconds_value_name(locale)) - }) - }); - command = command.mut_subcommand("uninstall-service", |sub| { - sub.about(uninstall_launchd_about(locale)) - .version(APP_VERSION) - .disable_help_flag(true) - .disable_version_flag(true) - .disable_help_subcommand(true) - .help_template(help_template(locale)) - }); - - command -} - -fn detect_locale() -> Locale { - let mut candidates = Vec::new(); - if let Ok(value) = std::env::var("CODEX_THREADRIPPER_LANG") { - candidates.push(value); - } - for key in ["LC_ALL", "LC_MESSAGES", "LANG"] { - if let Ok(value) = std::env::var(key) { - candidates.push(value); - } - } - - detect_locale_from_sources( - candidates.iter().map(String::as_str), - apple_languages_output().as_deref(), - ) -} - -fn detect_locale_from_sources<'a, I>(candidates: I, apple_languages: Option<&str>) -> Locale -where - I: IntoIterator, -{ - for candidate in candidates { - if let Some(locale) = parse_locale_tag(candidate) { - return locale; - } - } - if let Some(output) = apple_languages - && let Some(locale) = parse_apple_languages(output) - { - return locale; - } - Locale::En -} - -fn parse_locale_tag(input: &str) -> Option { - let normalized = input.trim().to_ascii_lowercase(); - if normalized.is_empty() { - return None; - } - if normalized.starts_with("zh") { - return Some(Locale::ZhHans); - } - if normalized.starts_with("en") { - return Some(Locale::En); - } - None -} - -fn parse_apple_languages(output: &str) -> Option { - output - .split(|ch: char| !(ch.is_ascii_alphanumeric() || ch == '-' || ch == '_')) - .find_map(parse_locale_tag) -} - -fn apple_languages_output() -> Option { - if !cfg!(target_os = "macos") { - return None; - } - let output = ProcessCommand::new("defaults") - .args(["read", "-g", "AppleLanguages"]) - .output() - .ok()?; - if !output.status.success() { - return None; - } - String::from_utf8(output.stdout).ok() -} - fn default_codex_home() -> PathBuf { std::env::var_os("HOME") .map(PathBuf::from) @@ -771,2827 +157,58 @@ fn default_codex_home() -> PathBuf { .join(".codex") } -fn run_watch( +fn install_service( locale: Locale, - codex_home: &Path, - provider_override: Option, - profile_override: Option, - rollout_scope: RolloutScope, - poll_interval: Duration, -) -> Result<()> { - let lock_path = codex_home.join("watch.lock"); - let watch_lock = OpenOptions::new() - .create(true) - .truncate(false) - .read(true) - .write(true) - .open(&lock_path) - .with_context(|| format!("failed to open {}", lock_path.display()))?; - if let Err(err) = watch_lock.try_lock_exclusive() { - if err.kind() == std::io::ErrorKind::WouldBlock { - anyhow::bail!(watch_already_running_error(locale)); - } - return Err(err).with_context(|| format!("failed to lock {}", lock_path.display())); - } - - let config_path = codex_home.join("config.toml"); - let mut watched_paths = watched_config_paths(codex_home, profile_override.as_deref()); - let full_rollout_scope = full_watch_rollout_scope(rollout_scope); - let shutdown = Arc::new(AtomicBool::new(false)); - let shutdown_for_handler = Arc::clone(&shutdown); - ctrlc::set_handler(move || { - shutdown_for_handler.store(true, Ordering::Relaxed); - })?; - - let (tx, rx) = mpsc::channel(); - let mut watcher = RecommendedWatcher::new( - move |event| { - let _ = tx.send(event); - }, - NotifyConfig::default(), - )?; - - if let Some(parent) = config_path.parent() - && parent.exists() - { - watcher.watch(parent, RecursiveMode::NonRecursive)?; - } - - let mut last_provider = None; - match reconcile_once( - codex_home, - provider_override.as_deref(), - profile_override.as_deref(), - full_rollout_scope, - ) { - Ok(summary) => { - print_sync_summary(locale, watch_started_title(locale), &summary); - last_provider = Some(summary.provider.clone()); - } - Err(err) => { - eprintln!("{}", watch_initial_reconcile_error_message(locale, &err)); - } - } - println!( - "{}", - watch_running_message(locale, codex_home, poll_interval) - ); - - let mut next_poll_deadline = Instant::now() + poll_interval; - let mut poll_count = 0_u64; - - while !shutdown.load(Ordering::Relaxed) { - let timeout = next_poll_deadline.saturating_duration_since(Instant::now()); - match rx.recv_timeout(timeout) { - Ok(Ok(event)) => { - if touches_config_file(&event, watched_paths.as_slice()) { - match reconcile_once( - codex_home, - provider_override.as_deref(), - profile_override.as_deref(), - full_rollout_scope, - ) { - Ok(summary) => { - if last_provider.as_deref() != Some(summary.provider.as_str()) - || summary.changed_rows > 0 - || summary.changed_rollouts > 0 - { - print_sync_summary(locale, config_change_title(locale), &summary); - } - last_provider = Some(summary.provider.clone()); - watched_paths = - watched_config_paths(codex_home, profile_override.as_deref()); - } - Err(err) => { - eprintln!("{}", watch_reconcile_skipped_message(locale, &err)); - } - } - next_poll_deadline = Instant::now() + poll_interval; - } - } - Ok(Err(err)) => { - eprintln!("{}", watcher_error_message(locale, err)); - } - Err(mpsc::RecvTimeoutError::Timeout) => { - poll_count = poll_count.wrapping_add(1); - match reconcile_once( - codex_home, - provider_override.as_deref(), - profile_override.as_deref(), - periodic_watch_rollout_scope(rollout_scope, poll_count), - ) { - Ok(summary) => { - if last_provider.as_deref() != Some(summary.provider.as_str()) - || summary.changed_rows > 0 - || summary.changed_rollouts > 0 - { - print_sync_summary( - locale, - background_reconcile_title(locale), - &summary, - ); - } - last_provider = Some(summary.provider.clone()); - } - Err(err) => { - eprintln!("{}", watch_reconcile_skipped_message(locale, &err)); - } - } - next_poll_deadline = Instant::now() + poll_interval; - } - Err(mpsc::RecvTimeoutError::Disconnected) => { - anyhow::bail!(watcher_disconnected_error(locale)); - } - } - } - println!("{}", watch_stopped_message(locale)); - Ok(()) -} - -fn periodic_watch_rollout_scope(rollout_scope: RolloutScope, poll_count: u64) -> RolloutScope { - if poll_count.is_multiple_of(WATCH_FULL_ROLLOUT_POLL_INTERVALS) { - return full_watch_rollout_scope(rollout_scope); - } - rollout_scope -} - -fn full_watch_rollout_scope(rollout_scope: RolloutScope) -> RolloutScope { - match rollout_scope { - RolloutScope::None => RolloutScope::None, - RolloutScope::MismatchedRows | RolloutScope::AllRows => RolloutScope::AllRows, - } -} - -fn watched_config_paths(codex_home: &Path, profile_override: Option<&str>) -> Vec { - let mut paths = vec![codex_home.join("config.toml")]; - let profile = profile_override.and_then(trimmed_string).or_else(|| { - read_codex_config(codex_home) - .ok() - .and_then(|config| config.profile.as_deref().and_then(trimmed_string)) - }); - if let Some(profile) = profile - && is_valid_profile_name(profile.as_str()) - { - paths.push(profile_config_path(codex_home, profile.as_str())); - } - paths -} - -fn touches_config_file(event: ¬ify::Event, config_paths: &[PathBuf]) -> bool { - matches!( - event.kind, - EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_) - ) && event - .paths - .iter() - .any(|path| config_paths.iter().any(|config_path| path == config_path)) -} - -fn collect_status( - codex_home: &Path, - provider_override: Option<&str>, - profile_override: Option<&str>, -) -> Result { - let config_path = codex_home.join("config.toml"); - let sqlite_path = resolve_sqlite_path(codex_home, profile_override)?; - let provider = match provider_override { - Some(provider) => provider.to_string(), - None => read_provider_from_config(codex_home, profile_override)?, - }; - let (total_rows, mismatched_rows, distribution) = - inspect_sqlite_distribution(&sqlite_path, provider.as_str())?; - let service_status = service::current_service_status()?; - - Ok(StatusSummary { - codex_home: codex_home.to_path_buf(), - sqlite_path, - config_path, - provider, - total_rows, - mismatched_rows, - distribution, - service_status, - }) -} - -fn reconcile_once( codex_home: &Path, provider_override: Option<&str>, profile_override: Option<&str>, - rollout_scope: RolloutScope, -) -> Result { - reconcile_once_with_progress( + poll_interval: Duration, +) -> Result<()> { + let exe_path = std::env::current_exe().context(current_exe_error(locale))?; + let summary = service::install_service( + exe_path.as_path(), codex_home, provider_override, profile_override, - rollout_scope, - None, - ) -} - -fn reconcile_once_with_progress( - codex_home: &Path, - provider_override: Option<&str>, - profile_override: Option<&str>, - rollout_scope: RolloutScope, - progress: Option, -) -> Result { - let provider = match provider_override { - Some(provider) => provider.to_string(), - None => read_provider_from_config(codex_home, profile_override)?, - }; - let sqlite_path = resolve_sqlite_path(codex_home, profile_override)?; - let started = Instant::now(); - let rollout_summary = reconcile_rollout_metadata_from_sqlite_with_progress( - &sqlite_path, - codex_home, - provider.as_str(), - rollout_scope, - None, - DEFAULT_BUCKET_PADDING_BYTES, - progress, + poll_interval, )?; - let (changed_rows, total_rows) = reconcile_sqlite_in_place(&sqlite_path, provider.as_str())?; - Ok(ReconcileSummary { - provider, - changed_rows, - total_rows, - changed_rollouts: rollout_summary.changed_files, - checked_rollouts: rollout_summary.checked_files, - prepared_rollouts: rollout_summary.prepared_files, - skipped_rollouts: rollout_summary.skipped_files, - elapsed: started.elapsed(), - backup_path: None, - rollout_journal_path: rollout_summary.journal_path, - }) -} - -fn reconcile_once_with_backup_progress( - codex_home: &Path, - provider_override: Option<&str>, - profile_override: Option<&str>, - rollout_scope: RolloutScope, - progress: Option, -) -> Result { - reconcile_once_with_backup_and_padding( + print_install_service_summary(locale, codex_home, poll_interval, &summary); + println!(); + println!("{}", next_steps_heading(locale)); + for line in install_next_steps( + locale, + exe_path.as_path(), codex_home, provider_override, profile_override, - rollout_scope, - DEFAULT_BUCKET_PADDING_BYTES, - progress, - ) -} - -fn reconcile_once_with_backup_and_padding( - codex_home: &Path, - provider_override: Option<&str>, - profile_override: Option<&str>, - rollout_scope: RolloutScope, - padding_bytes: usize, - progress: Option, -) -> Result { - let provider = match provider_override { - Some(provider) => provider.to_string(), - None => read_provider_from_config(codex_home, profile_override)?, - }; - let sqlite_path = resolve_sqlite_path(codex_home, profile_override)?; - let started = Instant::now(); - let backup_path = create_sqlite_backup_file(&sqlite_path)?; - let rollout_journal_path = - backup_path - .parent() - .unwrap_or_else(|| Path::new(".")) - .join(format!( - "rollouts.{}.jsonl", - backup_path - .file_name() - .and_then(|name| name.to_str()) - .unwrap_or("state-db.bak") - )); - let rollout_summary = reconcile_rollout_metadata_from_sqlite_with_progress( - &sqlite_path, - codex_home, - provider.as_str(), - rollout_scope, - Some(rollout_journal_path.as_path()), - padding_bytes, - progress, - )?; - let (changed_rows, total_rows) = reconcile_sqlite_in_place(&sqlite_path, provider.as_str())?; - - Ok(ReconcileSummary { - provider, - changed_rows, - total_rows, - changed_rollouts: rollout_summary.changed_files, - checked_rollouts: rollout_summary.checked_files, - prepared_rollouts: rollout_summary.prepared_files, - skipped_rollouts: rollout_summary.skipped_files, - elapsed: started.elapsed(), - backup_path: Some(backup_path), - rollout_journal_path: rollout_summary.journal_path, - }) -} - -fn read_provider_from_config(codex_home: &Path, profile_override: Option<&str>) -> Result { - let parsed = read_effective_codex_config(codex_home, profile_override)?; - Ok(parsed - .model_provider - .filter(|s| !s.trim().is_empty()) - .unwrap_or_else(|| DEFAULT_PROVIDER.to_string())) -} - -fn resolve_sqlite_path(codex_home: &Path, profile_override: Option<&str>) -> Result { - let parsed = read_effective_codex_config(codex_home, profile_override)?; - let current_dir = - std::env::current_dir().context("failed to resolve current directory for sqlite_home")?; - Ok(resolve_sqlite_home_from_config( - codex_home, - parsed.sqlite_home.as_deref(), - std::env::var(CODEX_SQLITE_HOME_ENV).ok().as_deref(), - current_dir.as_path(), - ) - .join(STATE_DB_FILENAME)) -} - -fn read_effective_codex_config( - codex_home: &Path, - profile_override: Option<&str>, -) -> Result { - let mut config = read_codex_config(codex_home)?; - let explicit_profile = profile_override.and_then(trimmed_string); - let config_profile = config.profile.as_deref().and_then(trimmed_string); - - if let Some(profile) = explicit_profile { - apply_profile_config(codex_home, &mut config, profile.as_str(), true)?; - } else if let Some(profile) = config_profile { - apply_profile_config(codex_home, &mut config, profile.as_str(), false)?; - } - - Ok(config) -} - -fn apply_profile_config( - codex_home: &Path, - config: &mut ConfigToml, - profile: &str, - required: bool, -) -> Result<()> { - if !is_valid_profile_name(profile) { - anyhow::bail!("profile `{profile}` is not a valid Codex profile name"); - } - if let Some(profile_config) = read_profile_v2_config(codex_home, profile)? { - config.overlay(profile_config); - return Ok(()); - } - if let Some(profile_config) = config - .profiles - .as_ref() - .and_then(|profiles| profiles.get(profile)) - { - if let Some(provider) = profile_config.model_provider.as_ref() { - config.model_provider = Some(provider.clone()); - } - return Ok(()); - } - if required { - anyhow::bail!( - "profile `{}` was not found; expected {} or [profiles.{}] in {}", - profile, - profile_config_path(codex_home, profile).display(), - profile, - codex_home.join("config.toml").display() - ); + summary.manager, + )? { + println!("{line}"); } Ok(()) } -fn read_codex_config(codex_home: &Path) -> Result { - read_optional_config_file(codex_home.join("config.toml").as_path()) - .map(|config| config.unwrap_or_default()) -} - -fn read_profile_v2_config(codex_home: &Path, profile: &str) -> Result> { - let path = profile_config_path(codex_home, profile); - read_optional_config_file(path.as_path()) -} - -fn read_optional_config_file(path: &Path) -> Result> { - let raw = match std::fs::read_to_string(path) { - Ok(raw) => raw, - Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None), - Err(err) => return Err(err).with_context(|| format!("failed to read {}", path.display())), - }; - toml::from_str(&raw) - .map(Some) - .with_context(|| format!("failed to parse {}", path.display())) -} - -fn profile_config_path(codex_home: &Path, profile: &str) -> PathBuf { - codex_home.join(format!("{profile}{PROFILE_CONFIG_SUFFIX}")) -} - -fn trimmed_string(value: &str) -> Option { - let trimmed = value.trim(); - if trimmed.is_empty() { - None - } else { - Some(trimmed.to_string()) - } -} - -fn resolve_sqlite_home_from_config( - codex_home: &Path, - config_sqlite_home: Option<&str>, - env_sqlite_home: Option<&str>, - current_dir: &Path, -) -> PathBuf { - if let Some(path) = config_sqlite_home.and_then(trimmed_path) { - return resolve_path_relative_to(path, codex_home); - } - if let Some(path) = env_sqlite_home.and_then(trimmed_path) { - return resolve_path_relative_to(path, current_dir); - } - codex_home.to_path_buf() -} - -fn trimmed_path(value: &str) -> Option<&Path> { - let trimmed = value.trim(); - if trimmed.is_empty() { - None +fn uninstall_service(locale: Locale, codex_home: &Path) -> Result<()> { + let service_status = service::current_service_status()?; + if service_status.installed { + let config_path = service::uninstall_service()?; + println!("{}", uninstall_launchd_done(locale)); + println!("{}", launchd_plist_message(locale, &config_path)); + println!(); + println!("{}", next_steps_heading(locale)); + println!( + "{}", + run_status_next_step( + locale, + &cli_status_command(std::env::current_exe()?, codex_home, None, None) + ) + ); } else { - Some(Path::new(trimmed)) - } -} - -fn resolve_path_relative_to(path: &Path, base: &Path) -> PathBuf { - if path.is_absolute() { - path.to_path_buf() - } else { - base.join(path) - } -} - -fn reconcile_rollout_metadata_from_sqlite_with_progress( - sqlite_path: &Path, - _codex_home: &Path, - provider: &str, - scope: RolloutScope, - journal_path: Option<&Path>, - padding_bytes: usize, - progress: Option, -) -> Result { - if scope == RolloutScope::None { - return Ok(RolloutReconcileSummary::default()); - } - let targets = rollout_targets_for_scope(sqlite_path, provider, scope)?; - reconcile_rollout_metadata_files( - targets.as_slice(), - provider, - journal_path, - padding_bytes, - progress, - ) -} - -fn rollout_targets_for_scope( - sqlite_path: &Path, - provider: &str, - scope: RolloutScope, -) -> Result> { - ensure_sqlite_exists(sqlite_path)?; - let connection = Connection::open(sqlite_path) - .with_context(|| format!("failed to open {}", sqlite_path.display()))?; - let sql = match scope { - RolloutScope::None => return Ok(Vec::new()), - RolloutScope::MismatchedRows => { - "SELECT id, rollout_path FROM threads WHERE model_provider <> ?1 AND rollout_path <> '' ORDER BY updated_at DESC" - } - RolloutScope::AllRows => { - "SELECT id, rollout_path FROM threads WHERE rollout_path <> '' ORDER BY updated_at DESC" - } - }; - let mut statement = connection.prepare(sql)?; - if scope == RolloutScope::MismatchedRows { - let rows = statement.query_map([provider], |row| { - Ok(RolloutTarget { - thread_id: row.get(0)?, - path: PathBuf::from(row.get::<_, String>(1)?), - }) - })?; - rows.collect::>>() - .map_err(Into::into) - } else { - let rows = statement.query_map([], |row| { - Ok(RolloutTarget { - thread_id: row.get(0)?, - path: PathBuf::from(row.get::<_, String>(1)?), - }) - })?; - rows.collect::>>() - .map_err(Into::into) - } -} - -fn reconcile_rollout_metadata_files( - targets: &[RolloutTarget], - provider: &str, - journal_path: Option<&Path>, - padding_bytes: usize, - progress: Option, -) -> Result { - let mut summary = RolloutReconcileSummary::default(); - let mut journal = journal_path.map(|path| RolloutChangeJournal::new(path.to_path_buf())); - let mut progress = progress.map(|config| RolloutProgress::new(config, targets.len() as u64)); - for target in targets { - if !target.path.exists() { - if let Some(progress) = progress.as_mut() { - progress.tick(&summary); - } - continue; - } - summary.checked_files += 1; - let outcome = - rewrite_rollout_provider_first_line(target, provider, journal.as_mut(), padding_bytes)?; - if outcome.changed { - summary.changed_files += 1; - } - if outcome.prepared { - summary.prepared_files += 1; - } - if outcome.skipped { - summary.skipped_files += 1; - } - if let Some(progress) = progress.as_mut() { - progress.tick(&summary); - } - } - if let Some(progress) = progress.as_mut() { - progress.finish(&summary); - } - if let Some(journal) = journal { - summary.journal_path = journal.finish()?; - } - Ok(summary) -} - -fn rewrite_rollout_provider_first_line( - target: &RolloutTarget, - provider: &str, - journal: Option<&mut RolloutChangeJournal>, - padding_bytes: usize, -) -> Result { - let first_line = read_first_line(&target.path)?; - let Some(mut value) = - parse_matching_session_meta(first_line.content.as_slice(), target.thread_id.as_str())? - else { - return Ok(RolloutPatchOutcome { - skipped: true, - ..RolloutPatchOutcome::default() - }); - }; - let old_provider = session_meta_provider(&value).unwrap_or("").to_string(); - - if !set_session_meta_provider(&mut value, provider) { - return Ok(RolloutPatchOutcome::default()); - } - - let rendered = serde_json::to_vec(&value) - .with_context(|| format!("failed to render {}", target.path.display()))?; - if rendered.len() <= first_line.content.len() { - let mut replacement = rendered; - replacement.resize(first_line.content.len(), b' '); - record_rollout_change( - journal, - target, - old_provider.as_str(), - provider, - first_line.content.len(), - replacement.len(), - RolloutChangeMode::InPlace, - )?; - patch_first_line_in_place( - &target.path, - replacement.as_slice(), - first_line.newline.as_slice(), - )?; - return Ok(RolloutPatchOutcome { - changed: true, - ..RolloutPatchOutcome::default() - }); - } - - let mut replacement = rendered; - replacement.resize(replacement.len() + padding_bytes, b' '); - record_rollout_change( - journal, - target, - old_provider.as_str(), - provider, - first_line.content.len(), - replacement.len(), - RolloutChangeMode::RewriteWithPadding, - )?; - rewrite_first_line_atomically( - &target.path, - replacement.as_slice(), - first_line.newline.as_slice(), - )?; - Ok(RolloutPatchOutcome { - changed: true, - prepared: true, - ..RolloutPatchOutcome::default() - }) -} - -fn prepare_bucket_padding( - codex_home: &Path, - profile_override: Option<&str>, - padding_bytes: usize, -) -> Result { - let sqlite_path = resolve_sqlite_path(codex_home, profile_override)?; - let started = Instant::now(); - let targets = rollout_targets_for_scope(&sqlite_path, DEFAULT_PROVIDER, RolloutScope::AllRows)?; - let journal_path = codex_home - .join("backups") - .join(format!("bucket-prepare.{}.jsonl", unix_timestamp_millis()?)); - let mut journal = RolloutChangeJournal::new(journal_path); - let mut checked_rollouts = 0; - let mut prepared_rollouts = 0; - let mut skipped_rollouts = 0; - - for target in &targets { - if !target.path.exists() { - continue; - } - checked_rollouts += 1; - let outcome = prepare_rollout_first_line_padding(target, &mut journal, padding_bytes)?; - if outcome.prepared { - prepared_rollouts += 1; - } - if outcome.skipped { - skipped_rollouts += 1; - } - } - let journal_path = journal.finish()?; - - Ok(BucketPrepareSummary { - checked_rollouts, - prepared_rollouts, - skipped_rollouts, - elapsed: started.elapsed(), - journal_path, - }) -} - -fn prepare_rollout_first_line_padding( - target: &RolloutTarget, - journal: &mut RolloutChangeJournal, - padding_bytes: usize, -) -> Result { - let first_line = read_first_line(&target.path)?; - let Some(value) = - parse_matching_session_meta(first_line.content.as_slice(), target.thread_id.as_str())? - else { - return Ok(RolloutPatchOutcome { - skipped: true, - ..RolloutPatchOutcome::default() - }); - }; - let old_provider = session_meta_provider(&value).unwrap_or("").to_string(); - - let rendered = serde_json::to_vec(&value) - .with_context(|| format!("failed to render {}", target.path.display()))?; - let desired_len = rendered.len() + padding_bytes; - if first_line.content.len() >= desired_len { - return Ok(RolloutPatchOutcome::default()); - } - - let mut replacement = rendered; - replacement.resize(desired_len, b' '); - record_rollout_change( - Some(journal), - target, - old_provider.as_str(), - old_provider.as_str(), - first_line.content.len(), - replacement.len(), - RolloutChangeMode::RewriteWithPadding, - )?; - rewrite_first_line_atomically( - &target.path, - replacement.as_slice(), - first_line.newline.as_slice(), - )?; - Ok(RolloutPatchOutcome { - changed: true, - prepared: true, - ..RolloutPatchOutcome::default() - }) -} - -fn read_first_line(path: &Path) -> Result { - let file = File::open(path).with_context(|| format!("failed to open {}", path.display()))?; - let mut reader = BufReader::new(file); - let mut buffer = Vec::new(); - reader - .read_until(b'\n', &mut buffer) - .with_context(|| format!("failed to read {}", path.display()))?; - if buffer.is_empty() { - anyhow::bail!("empty rollout file: {}", path.display()); - } - - let mut newline = Vec::new(); - if buffer.ends_with(b"\n") { - buffer.pop(); - if buffer.ends_with(b"\r") { - buffer.pop(); - newline.extend_from_slice(b"\r\n"); - } else { - newline.push(b'\n'); - } - } - - Ok(FirstLine { - content: buffer, - newline, - }) -} - -fn parse_matching_session_meta(line: &[u8], thread_id: &str) -> Result> { - let value = serde_json::from_slice::(line)?; - if session_meta_belongs_to_thread(&value, thread_id) { - Ok(Some(value)) - } else { - Ok(None) - } -} - -fn patch_first_line_in_place(path: &Path, replacement: &[u8], newline: &[u8]) -> Result<()> { - let original_times = capture_file_times(path)?; - { - let mut file = OpenOptions::new() - .write(true) - .open(path) - .with_context(|| format!("failed to open {}", path.display()))?; - file.seek(SeekFrom::Start(0)) - .with_context(|| format!("failed to seek {}", path.display()))?; - file.write_all(replacement) - .with_context(|| format!("failed to write {}", path.display()))?; - file.write_all(newline) - .with_context(|| format!("failed to write {}", path.display()))?; - } - restore_file_times(path, original_times)?; - Ok(()) -} - -fn rewrite_first_line_atomically(path: &Path, replacement: &[u8], newline: &[u8]) -> Result<()> { - let parent = path.parent().unwrap_or_else(|| Path::new(".")); - let file_name = path - .file_name() - .and_then(|name| name.to_str()) - .unwrap_or("rollout.jsonl"); - let temp_path = parent.join(format!( - ".{file_name}.{}.threadripper.tmp", - std::process::id() - )); - let metadata = - fs::metadata(path).with_context(|| format!("failed to stat {}", path.display()))?; - let original_times = file_times_from_metadata(&metadata); - - { - let input = - File::open(path).with_context(|| format!("failed to open {}", path.display()))?; - let mut reader = BufReader::new(input); - let mut ignored_first_line = Vec::new(); - reader - .read_until(b'\n', &mut ignored_first_line) - .with_context(|| format!("failed to read {}", path.display()))?; - - let output = File::create(&temp_path) - .with_context(|| format!("failed to create {}", temp_path.display()))?; - fs::set_permissions(&temp_path, metadata.permissions()) - .with_context(|| format!("failed to set permissions on {}", temp_path.display()))?; - let mut writer = BufWriter::new(output); - writer - .write_all(replacement) - .with_context(|| format!("failed to write {}", temp_path.display()))?; - writer - .write_all(newline) - .with_context(|| format!("failed to write {}", temp_path.display()))?; - std::io::copy(&mut reader, &mut writer) - .with_context(|| format!("failed to copy {}", path.display()))?; - writer - .flush() - .with_context(|| format!("failed to flush {}", temp_path.display()))?; - writer - .get_ref() - .sync_all() - .with_context(|| format!("fsync: {}", temp_path.display()))?; - } - - fs::rename(&temp_path, path).with_context(|| { - format!( - "failed to replace {} with {}", - path.display(), - temp_path.display() - ) - })?; - restore_file_times(path, original_times)?; - sync_dir(parent)?; - Ok(()) -} - -fn capture_file_times(path: &Path) -> Result<(FileTime, FileTime)> { - let metadata = - fs::metadata(path).with_context(|| format!("failed to stat {}", path.display()))?; - Ok(file_times_from_metadata(&metadata)) -} - -fn file_times_from_metadata(metadata: &fs::Metadata) -> (FileTime, FileTime) { - ( - FileTime::from_last_access_time(metadata), - FileTime::from_last_modification_time(metadata), - ) -} - -fn restore_file_times(path: &Path, times: (FileTime, FileTime)) -> Result<()> { - set_file_times(path, times.0, times.1) - .with_context(|| format!("failed to restore file times for {}", path.display())) -} - -fn record_rollout_change( - journal: Option<&mut RolloutChangeJournal>, - target: &RolloutTarget, - old_provider: &str, - new_provider: &str, - old_len: usize, - new_len: usize, - mode: RolloutChangeMode, -) -> Result<()> { - if let Some(journal) = journal { - journal.record(target, old_provider, new_provider, old_len, new_len, mode)?; - } - Ok(()) -} - -impl RolloutChangeJournal { - fn new(path: PathBuf) -> Self { - Self { path, writer: None } - } - - fn record( - &mut self, - target: &RolloutTarget, - old_provider: &str, - new_provider: &str, - old_len: usize, - new_len: usize, - mode: RolloutChangeMode, - ) -> Result<()> { - if self.writer.is_none() { - if let Some(parent) = self.path.parent() { - fs::create_dir_all(parent) - .with_context(|| format!("failed to create {}", parent.display()))?; - } - let file = File::create(&self.path) - .with_context(|| format!("failed to create {}", self.path.display()))?; - self.writer = Some(BufWriter::new(file)); - } - - let writer = self.writer.as_mut().expect("journal writer initialized"); - serde_json::to_writer( - &mut *writer, - &json!({ - "path": target.path.display().to_string(), - "thread_id": target.thread_id.as_str(), - "mode": mode.as_str(), - "old_provider": old_provider, - "new_provider": new_provider, - "old_first_line_len": old_len, - "new_first_line_len": new_len, - }), - ) - .with_context(|| format!("failed to write {}", self.path.display()))?; - writer - .write_all(b"\n") - .with_context(|| format!("failed to write {}", self.path.display()))?; - Ok(()) - } - - fn finish(mut self) -> Result> { - let Some(mut writer) = self.writer.take() else { - return Ok(None); - }; - writer - .flush() - .with_context(|| format!("failed to flush {}", self.path.display()))?; - writer - .get_ref() - .sync_all() - .with_context(|| format!("fsync: {}", self.path.display()))?; - if let Some(parent) = self.path.parent() { - sync_dir(parent)?; - } - Ok(Some(self.path)) - } -} - -impl RolloutChangeMode { - fn as_str(self) -> &'static str { - match self { - Self::InPlace => "in_place", - Self::RewriteWithPadding => "rewrite_with_padding", - } - } -} - -fn session_meta_belongs_to_thread(value: &Value, thread_id: &str) -> bool { - value.get("type").and_then(Value::as_str) == Some("session_meta") - && value - .get("payload") - .and_then(|payload| payload.get("id")) - .and_then(Value::as_str) - == Some(thread_id) -} - -fn session_meta_provider(value: &Value) -> Option<&str> { - value - .get("payload") - .and_then(|payload| payload.get("model_provider")) - .and_then(Value::as_str) -} - -fn set_session_meta_provider(value: &mut Value, provider: &str) -> bool { - let Some(payload) = value.get_mut("payload").and_then(Value::as_object_mut) else { - return false; - }; - if payload - .get("model_provider") - .and_then(Value::as_str) - .is_some_and(|current| current == provider) - { - return false; - } - payload.insert( - "model_provider".to_string(), - Value::String(provider.to_string()), - ); - true -} - -fn inspect_sqlite_distribution( - sqlite_path: &Path, - target_provider: &str, -) -> Result<(u64, u64, ProviderDistribution)> { - ensure_sqlite_exists(sqlite_path)?; - let connection = Connection::open(sqlite_path) - .with_context(|| format!("failed to open {}", sqlite_path.display()))?; - let total_rows: u64 = - connection.query_row("SELECT COUNT(*) FROM threads", [], |row| row.get(0))?; - let mismatched_rows: u64 = connection.query_row( - "SELECT COUNT(*) FROM threads WHERE model_provider <> ?1", - [target_provider], - |row| row.get(0), - )?; - let mut statement = connection.prepare( - "SELECT model_provider, COUNT(*) AS row_count FROM threads GROUP BY model_provider ORDER BY row_count DESC, model_provider ASC", - )?; - let distribution = statement - .query_map([], |row| { - Ok((row.get::<_, String>(0)?, row.get::<_, u64>(1)?)) - })? - .collect::>>()?; - - Ok((total_rows, mismatched_rows, distribution)) -} - -fn reconcile_sqlite_in_place(sqlite_path: &Path, provider: &str) -> Result<(u64, u64)> { - ensure_sqlite_exists(sqlite_path)?; - let mut connection = Connection::open(sqlite_path) - .with_context(|| format!("failed to open {}", sqlite_path.display()))?; - connection.busy_timeout(Duration::from_secs(5))?; - - let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?; - let total_rows: u64 = - transaction.query_row("SELECT COUNT(*) FROM threads", [], |row| row.get(0))?; - let changed_rows = transaction.execute( - "UPDATE threads SET model_provider = ?1 WHERE model_provider <> ?1", - [provider], - )? as u64; - transaction.commit()?; - - Ok((changed_rows, total_rows)) -} - -#[cfg(test)] -fn reconcile_sqlite_with_backup(sqlite_path: &Path, provider: &str) -> Result<(u64, u64, PathBuf)> { - let backup_path = create_sqlite_backup_file(sqlite_path)?; - let (changed_rows, total_rows) = reconcile_sqlite_in_place(sqlite_path, provider)?; - Ok((changed_rows, total_rows, backup_path)) -} - -fn create_sqlite_backup_file(sqlite_path: &Path) -> Result { - let backups_dir = sqlite_path - .parent() - .unwrap_or_else(|| Path::new(".")) - .join("backups"); - fs::create_dir_all(&backups_dir) - .with_context(|| format!("failed to create {}", backups_dir.display()))?; - - let timestamp = unix_timestamp_millis()?; - let sqlite_name = sqlite_path - .file_name() - .and_then(|name| name.to_str()) - .unwrap_or(STATE_DB_FILENAME); - let backup_name = format!("{sqlite_name}.{timestamp}.bak"); - let backup_path = backups_dir.join(&backup_name); - let backup_temp_path = backups_dir.join(format!("{backup_name}.tmp")); - - if backup_temp_path.exists() { - fs::remove_file(&backup_temp_path) - .with_context(|| format!("failed to remove {}", backup_temp_path.display()))?; - } - - create_sqlite_backup(sqlite_path, &backup_temp_path)?; - fs::rename(&backup_temp_path, &backup_path) - .with_context(|| format!("failed to finalize {}", backup_path.display()))?; - sync_dir(&backups_dir)?; - - Ok(backup_path) -} - -fn unix_timestamp_millis() -> Result { - Ok(SystemTime::now() - .duration_since(UNIX_EPOCH) - .context("system clock is earlier than UNIX_EPOCH")? - .as_millis()) -} - -fn create_sqlite_backup(sqlite_path: &Path, backup_path: &Path) -> Result<()> { - ensure_sqlite_exists(sqlite_path)?; - let source = Connection::open(sqlite_path) - .with_context(|| format!("failed to open {}", sqlite_path.display()))?; - source.busy_timeout(Duration::from_millis(5_000))?; - let mut destination = Connection::open(backup_path) - .with_context(|| format!("failed to open {}", backup_path.display()))?; - let backup = Backup::new(&source, &mut destination)?; - let started = Instant::now(); - let timeout = Duration::from_secs(30); - - loop { - if started.elapsed() >= timeout { - anyhow::bail!( - "sqlite backup timed out after {} seconds for {}", - timeout.as_secs(), - sqlite_path.display() - ); - } - - match backup.step(100)? { - StepResult::Done => break, - StepResult::More => {} - StepResult::Busy | StepResult::Locked => { - std::thread::sleep(Duration::from_millis(50)); - } - _ => {} - } - } - - drop(backup); - drop(destination); - sync_file(backup_path)?; - - Ok(()) -} - -fn ensure_sqlite_exists(sqlite_path: &Path) -> Result<()> { - if sqlite_path.exists() { - return Ok(()); - } - - anyhow::bail!(sqlite_missing_error(detect_locale(), sqlite_path)); -} - -fn sync_file(path: &Path) -> Result<()> { - let file = OpenOptions::new() - .read(true) - .write(true) - .open(path) - .with_context(|| format!("open for sync: {}", path.display()))?; - file.sync_all() - .with_context(|| format!("fsync: {}", path.display()))?; - Ok(()) -} - -#[cfg(unix)] -fn sync_dir(path: &Path) -> Result<()> { - let dir = File::open(path).with_context(|| format!("open for sync: {}", path.display()))?; - dir.sync_all() - .with_context(|| format!("fsync: {}", path.display()))?; - Ok(()) -} - -#[cfg(not(unix))] -fn sync_dir(_path: &Path) -> Result<()> { - Ok(()) -} - -fn install_service( - locale: Locale, - codex_home: &Path, - provider_override: Option<&str>, - profile_override: Option<&str>, - poll_interval: Duration, -) -> Result<()> { - let exe_path = std::env::current_exe().context(current_exe_error(locale))?; - let summary = service::install_service( - exe_path.as_path(), - codex_home, - provider_override, - profile_override, - poll_interval, - )?; - - print_install_service_summary(locale, codex_home, poll_interval, &summary); - println!(); - println!("{}", next_steps_heading(locale)); - for line in install_next_steps( - locale, - exe_path.as_path(), - codex_home, - provider_override, - profile_override, - summary.manager, - )? { - println!("{line}"); - } - Ok(()) -} - -fn uninstall_service(locale: Locale, codex_home: &Path) -> Result<()> { - let service_status = service::current_service_status()?; - if service_status.installed { - let config_path = service::uninstall_service()?; - println!("{}", uninstall_launchd_done(locale)); - println!("{}", launchd_plist_message(locale, &config_path)); - println!(); - println!("{}", next_steps_heading(locale)); - println!( - "{}", - run_status_next_step( - locale, - &cli_status_command(std::env::current_exe()?, codex_home, None, None) - ) - ); - } else { - println!( - "{}", - no_launchd_plist_message(locale, &service_status.config_path) - ); + println!( + "{}", + no_launchd_plist_message(locale, &service_status.config_path) + ); } Ok(()) } - -fn print_status(locale: Locale, summary: &StatusSummary) { - println!("{}", status_title(locale)); - println!(); - println!( - "{}: {}", - status_codex_home_label(locale), - summary.codex_home.display() - ); - println!( - "{}: {}", - status_config_file_label(locale), - summary.config_path.display() - ); - println!( - "{}: {}", - status_sqlite_file_label(locale), - summary.sqlite_path.display() - ); - println!( - "{}: {}", - status_target_provider_label(locale), - summary.provider - ); - println!( - "{}: {}", - status_total_threads_label(locale), - summary.total_rows - ); - println!( - "{}: {}", - status_rows_needing_reconcile_label(locale), - summary.mismatched_rows - ); - println!(); - println!("{}", status_distribution_heading(locale)); - for (provider, count) in &summary.distribution { - println!(" {provider}: {count}"); - } - println!(); - println!("{}", status_background_service_heading(locale)); - println!( - " {}: {}", - status_service_manager_label(locale), - service::manager_name(summary.service_status.manager) - ); - println!( - " {}: {}", - status_plist_path_label(locale), - summary.service_status.config_path.display() - ); - println!( - " {}: {}", - status_installed_label(locale), - yes_no(locale, summary.service_status.installed) - ); - println!( - " {}: {}", - status_loaded_label(locale), - yes_no(locale, summary.service_status.running) - ); -} - -fn rollout_progress_message(locale: Locale, snapshot: &RolloutProgressSnapshot) -> String { - let RolloutProgressSnapshot { - visited_files, - total_files, - checked_files, - changed_files, - prepared_files, - skipped_files, - elapsed, - } = *snapshot; - match locale { - Locale::En => format!( - "Rollouts: scanned {visited_files}/{total_files}, checked {checked_files}, updated {changed_files}, prepared {prepared_files}, skipped {skipped_files}, elapsed {} ms", - elapsed.as_millis() - ), - Locale::ZhHans => format!( - "rollout 进度: 已扫描 {visited_files}/{total_files},已检查 {checked_files},已更新 {changed_files},已准备 {prepared_files},已跳过 {skipped_files},耗时 {} ms", - elapsed.as_millis() - ), - } -} - -fn print_sync_summary(locale: Locale, title: &str, summary: &ReconcileSummary) { - println!("{title}"); - println!( - "{}: {}", - status_target_provider_label(locale), - summary.provider - ); - println!( - "{}: {}", - sync_rows_updated_label(locale), - summary.changed_rows - ); - if summary.checked_rollouts > 0 || summary.changed_rollouts > 0 { - println!( - "{}: {}", - sync_rollouts_checked_label(locale), - summary.checked_rollouts - ); - println!( - "{}: {}", - sync_rollouts_updated_label(locale), - summary.changed_rollouts - ); - if summary.prepared_rollouts > 0 { - println!( - "{}: {}", - sync_rollouts_prepared_label(locale), - summary.prepared_rollouts - ); - } - if summary.skipped_rollouts > 0 { - println!( - "{}: {}", - sync_rollouts_skipped_label(locale), - summary.skipped_rollouts - ); - } - } - println!( - "{}: {}", - status_total_threads_label(locale), - summary.total_rows - ); - println!( - "{}: {} ms", - sync_elapsed_label(locale), - summary.elapsed.as_millis() - ); - if let Some(backup_path) = &summary.backup_path { - println!("{}: {}", sync_backup_label(locale), backup_path.display()); - } - if let Some(journal_path) = &summary.rollout_journal_path { - println!( - "{}: {}", - sync_rollout_journal_label(locale), - journal_path.display() - ); - } -} - -fn print_bucket_prepare_summary(locale: Locale, summary: &BucketPrepareSummary) { - println!("{}", bucket_prepare_complete_title(locale)); - println!( - "{}: {}", - sync_rollouts_checked_label(locale), - summary.checked_rollouts - ); - println!( - "{}: {}", - sync_rollouts_prepared_label(locale), - summary.prepared_rollouts - ); - if summary.skipped_rollouts > 0 { - println!( - "{}: {}", - sync_rollouts_skipped_label(locale), - summary.skipped_rollouts - ); - } - println!( - "{}: {} ms", - sync_elapsed_label(locale), - summary.elapsed.as_millis() - ); - if let Some(journal_path) = &summary.journal_path { - println!( - "{}: {}", - sync_rollout_journal_label(locale), - journal_path.display() - ); - } -} - -fn yes_no(locale: Locale, value: bool) -> &'static str { - match (locale, value) { - (Locale::En, true) => "yes", - (Locale::En, false) => "no", - (Locale::ZhHans, true) => "是", - (Locale::ZhHans, false) => "否", - } -} - -fn root_about(locale: Locale) -> &'static str { - match locale { - Locale::En => "Keep Codex's SQLite state DB aligned with the current model_provider.", - Locale::ZhHans => "让 Codex 的 SQLite 状态库持续对齐当前 model_provider。", - } -} - -fn root_long_about(locale: Locale) -> &'static str { - match locale { - Locale::En => { - "codex-threadripper is a human-first maintenance tool for Codex thread history.\n\nIt reads the effective Codex provider from CODEX_HOME/config.toml, including the selected profile config when present, and rewrites Codex's SQLite state DB so every thread stays in the same provider bucket. The DB defaults to CODEX_HOME/state_5.sqlite, but sqlite_home and CODEX_SQLITE_HOME are respected. That makes thread lists and resume flows stop fragmenting across providers.\n\nExamples:\n codex-threadripper status\n codex-threadripper sync\n codex-threadripper watch\n codex-threadripper install-service" - } - Locale::ZhHans => { - "codex-threadripper 是一个面向人的 Codex 线程历史维护工具。\n\n它会读取 CODEX_HOME/config.toml 和已选 profile 配置合成后的有效 provider,并改写 Codex 的 SQLite 状态库,让所有线程始终落在同一个 provider 桶里。状态库默认是 CODEX_HOME/state_5.sqlite,同时会尊重 sqlite_home 和 CODEX_SQLITE_HOME。这样线程列表和 resume 流程就不会再被 provider 切碎。\n\n示例:\n codex-threadripper status\n codex-threadripper sync\n codex-threadripper watch\n codex-threadripper install-service" - } - } -} - -fn help_template(locale: Locale) -> &'static str { - match locale { - Locale::En => "{before-help}{about-section}Usage: {usage}\n\n{all-args}{after-help}", - Locale::ZhHans => "{before-help}{about-section}用法:{usage}\n\n{all-args}{after-help}", - } -} - -fn commands_heading(locale: Locale) -> &'static str { - match locale { - Locale::En => "Commands", - Locale::ZhHans => "命令", - } -} - -fn options_heading(locale: Locale) -> &'static str { - match locale { - Locale::En => "Options", - Locale::ZhHans => "选项", - } -} - -fn dir_value_name(locale: Locale) -> &'static str { - match locale { - Locale::En => "DIR", - Locale::ZhHans => "目录", - } -} - -fn provider_value_name(locale: Locale) -> &'static str { - match locale { - Locale::En => "PROVIDER", - Locale::ZhHans => "PROVIDER", - } -} - -fn profile_value_name(locale: Locale) -> &'static str { - match locale { - Locale::En => "PROFILE", - Locale::ZhHans => "PROFILE", - } -} - -fn milliseconds_value_name(locale: Locale) -> &'static str { - match locale { - Locale::En => "MILLISECONDS", - Locale::ZhHans => "毫秒", - } -} - -fn bytes_value_name(locale: Locale) -> &'static str { - match locale { - Locale::En => "BYTES", - Locale::ZhHans => "字节", - } -} - -fn codex_home_help(locale: Locale) -> &'static str { - match locale { - Locale::En => "Codex home directory. Defaults to $HOME/.codex.", - Locale::ZhHans => "Codex home 目录。默认值是 $HOME/.codex。", - } -} - -fn provider_help(locale: Locale) -> &'static str { - match locale { - Locale::En => "Force a provider instead of reading model_provider from config.toml.", - Locale::ZhHans => "强制指定 provider,跳过从 config.toml 读取 model_provider。", - } -} - -fn profile_help(locale: Locale) -> &'static str { - match locale { - Locale::En => { - "Resolve model_provider and sqlite_home from a Codex profile config first. Profile names may contain ASCII letters, digits, '_' or '-'." - } - Locale::ZhHans => { - "优先从 Codex profile 配置解析 model_provider 和 sqlite_home。profile 名称可包含 ASCII 字母、数字、'_' 或 '-'。" - } - } -} - -fn provider_empty_error(locale: Locale) -> &'static str { - match locale { - Locale::En => "provider must contain at least one non-whitespace character", - Locale::ZhHans => "provider 需要包含至少一个非空白字符", - } -} - -fn profile_empty_error(locale: Locale) -> &'static str { - match locale { - Locale::En => "profile must contain at least one non-whitespace character", - Locale::ZhHans => "profile 需要包含至少一个非空白字符", - } -} - -fn profile_path_error(locale: Locale) -> &'static str { - match locale { - Locale::En => { - "profile must be a plain name containing only ASCII letters, digits, '_' or '-'" - } - Locale::ZhHans => "profile 需要是只包含 ASCII 字母、数字、'_' 或 '-' 的普通名称", - } -} - -fn help_option_help(locale: Locale) -> &'static str { - match locale { - Locale::En => "Print help", - Locale::ZhHans => "显示帮助信息", - } -} - -fn version_option_help(locale: Locale) -> &'static str { - match locale { - Locale::En => "Print version", - Locale::ZhHans => "显示版本号", - } -} - -fn status_about(locale: Locale) -> &'static str { - match locale { - Locale::En => { - "Show the current config provider, SQLite distribution, and background service state." - } - Locale::ZhHans => "显示当前 config provider、SQLite 分布和后台服务状态。", - } -} - -fn sync_about(locale: Locale) -> &'static str { - match locale { - Locale::En => "Reconcile Codex's SQLite state DB and rollout metadata once right now.", - Locale::ZhHans => "立刻收敛 Codex SQLite 状态库和 rollout 元数据。", - } -} - -fn bucket_about(locale: Locale) -> &'static str { - match locale { - Locale::En => "Manage the provider bucket label stored in rollout first lines.", - Locale::ZhHans => "管理 rollout 首行里的 provider 可见桶标签。", - } -} - -fn bucket_prepare_about(locale: Locale) -> &'static str { - match locale { - Locale::En => "Reserve first-line padding so future provider bucket switches stay fast.", - Locale::ZhHans => "给首行预留 padding,让后续 provider 桶切换保持快速。", - } -} - -fn bucket_switch_about(locale: Locale) -> &'static str { - match locale { - Locale::En => "Switch every thread into one provider bucket.", - Locale::ZhHans => "把所有线程切到同一个 provider 可见桶。", - } -} - -fn bucket_switch_provider_help(locale: Locale) -> &'static str { - match locale { - Locale::En => "Target provider bucket. Defaults to --provider or config.toml.", - Locale::ZhHans => "目标 provider 桶。默认读取 --provider 或 config.toml。", - } -} - -fn padding_bytes_help(locale: Locale) -> &'static str { - match locale { - Locale::En => "Extra JSON whitespace to reserve at the end of the rollout first line.", - Locale::ZhHans => "在 rollout 首行末尾预留的额外 JSON 空白字节数。", - } -} - -fn watch_about(locale: Locale) -> &'static str { - match locale { - Locale::En => { - "Keep watching config.toml and keep reconciling new rows in SQLite and rollout metadata." - } - Locale::ZhHans => "持续监听 config.toml,并持续收敛 SQLite 与 rollout 新增元数据。", - } -} - -fn poll_interval_help(locale: Locale) -> &'static str { - match locale { - Locale::En => "How often to reconcile SQLite while watching.", - Locale::ZhHans => "watch 模式下,定时收敛 SQLite 的频率。", - } -} - -fn sqlite_only_help(locale: Locale) -> &'static str { - match locale { - Locale::En => { - "Only update Codex's SQLite state DB; leave rollout JSONL metadata untouched." - } - Locale::ZhHans => "只更新 Codex SQLite 状态库,不改 rollout JSONL 元数据。", - } -} - -fn print_plist_about(locale: Locale) -> &'static str { - match locale { - Locale::En => "Print the platform-specific background service config for this tool.", - Locale::ZhHans => "打印这个工具在当前平台上的后台服务配置。", - } -} - -fn install_launchd_about(locale: Locale) -> &'static str { - match locale { - Locale::En => "Install and start the platform-specific background service.", - Locale::ZhHans => "安装并启动当前平台上的后台服务。", - } -} - -fn uninstall_launchd_about(locale: Locale) -> &'static str { - match locale { - Locale::En => "Stop and remove the platform-specific background service.", - Locale::ZhHans => "停止并移除当前平台上的后台服务。", - } -} - -fn launchd_poll_help(locale: Locale) -> &'static str { - match locale { - Locale::En => "Polling interval for the background watcher.", - Locale::ZhHans => "后台 watcher 的轮询间隔。", - } -} - -fn current_exe_error(locale: Locale) -> &'static str { - match locale { - Locale::En => "failed to resolve current executable", - Locale::ZhHans => "无法解析当前可执行文件路径", - } -} - -fn sync_complete_title(locale: Locale) -> &'static str { - match locale { - Locale::En => "Sync complete", - Locale::ZhHans => "同步完成", - } -} - -fn bucket_switch_complete_title(locale: Locale) -> &'static str { - match locale { - Locale::En => "Bucket switch complete", - Locale::ZhHans => "可见桶切换完成", - } -} - -fn bucket_prepare_complete_title(locale: Locale) -> &'static str { - match locale { - Locale::En => "Bucket prepare complete", - Locale::ZhHans => "可见桶准备完成", - } -} - -fn watch_started_title(locale: Locale) -> &'static str { - match locale { - Locale::En => "Watch started", - Locale::ZhHans => "已开始监听", - } -} - -fn config_change_title(locale: Locale) -> &'static str { - match locale { - Locale::En => "Config change detected", - Locale::ZhHans => "检测到配置变更", - } -} - -fn background_reconcile_title(locale: Locale) -> &'static str { - match locale { - Locale::En => "Background reconcile", - Locale::ZhHans => "后台收敛", - } -} - -fn watch_running_message(locale: Locale, codex_home: &Path, poll_interval: Duration) -> String { - match locale { - Locale::En => format!( - "Watching {} with a {} ms poll interval. Press Ctrl-C to stop.", - codex_home.display(), - poll_interval.as_millis() - ), - Locale::ZhHans => format!( - "正在监听 {},轮询间隔 {} ms。按 Ctrl-C 停止。", - codex_home.display(), - poll_interval.as_millis() - ), - } -} - -fn watch_initial_reconcile_error_message(locale: Locale, err: &anyhow::Error) -> String { - match locale { - Locale::En => format!( - "Initial reconcile hit an error. Watch will keep running and retry on config changes or the next poll: {err:#}" - ), - Locale::ZhHans => { - format!("首轮收敛遇到错误。watch 会继续运行,并在配置变更或下一次轮询时重试:{err:#}") - } - } -} - -fn watch_reconcile_skipped_message(locale: Locale, err: &anyhow::Error) -> String { - match locale { - Locale::En => format!("Reconcile is waiting for the next retry: {err:#}"), - Locale::ZhHans => format!("本轮收敛等待下一次重试:{err:#}"), - } -} - -fn watcher_error_message(locale: Locale, err: notify::Error) -> String { - match locale { - Locale::En => format!("Watcher error: {err}"), - Locale::ZhHans => format!("监听器错误:{err}"), - } -} - -fn watcher_disconnected_error(locale: Locale) -> &'static str { - match locale { - Locale::En => "watcher disconnected", - Locale::ZhHans => "监听器已断开", - } -} - -fn watch_already_running_error(locale: Locale) -> &'static str { - match locale { - Locale::En => "another watch is already running for this codex home", - Locale::ZhHans => "这个 codex home 已经有另一个 watch 在运行", - } -} - -fn watch_stopped_message(locale: Locale) -> &'static str { - match locale { - Locale::En => "Watch stopped.", - Locale::ZhHans => "监听已停止。", - } -} - -fn install_launchd_done(locale: Locale) -> &'static str { - match locale { - Locale::En => "Installed background service.", - Locale::ZhHans => "已安装后台服务。", - } -} - -fn uninstall_launchd_done(locale: Locale) -> &'static str { - match locale { - Locale::En => "Removed background service.", - Locale::ZhHans => "已移除后台服务。", - } -} - -fn launchd_label_message(locale: Locale, label: &str) -> String { - match locale { - Locale::En => format!("Service label: {label}"), - Locale::ZhHans => format!("服务标签:{label}"), - } -} - -fn launchd_plist_message(locale: Locale, path: &Path) -> String { - match locale { - Locale::En => format!("Config path: {}", path.display()), - Locale::ZhHans => format!("配置路径:{}", path.display()), - } -} - -fn launchd_codex_home_message(locale: Locale, path: &Path) -> String { - match locale { - Locale::En => format!("Codex home: {}", path.display()), - Locale::ZhHans => format!("Codex home:{}", path.display()), - } -} - -fn launchd_polling_message(locale: Locale, poll_interval: Duration) -> String { - match locale { - Locale::En => format!("Polling every {} ms.", poll_interval.as_millis()), - Locale::ZhHans => format!("轮询间隔:{} ms。", poll_interval.as_millis()), - } -} - -fn next_steps_heading(locale: Locale) -> &'static str { - match locale { - Locale::En => "Next steps", - Locale::ZhHans => "下一步", - } -} - -fn print_install_service_summary( - locale: Locale, - codex_home: &Path, - poll_interval: Duration, - summary: &ServiceInstallSummary, -) { - println!("{}", install_launchd_done(locale)); - println!( - "{}: {}", - status_service_manager_label(locale), - service::manager_name(summary.manager) - ); - println!("{}", launchd_label_message(locale, service::SERVICE_LABEL)); - println!("{}", launchd_plist_message(locale, &summary.config_path)); - println!("{}", launchd_codex_home_message(locale, codex_home)); - println!("{}", launchd_polling_message(locale, poll_interval)); - println!("{}", service_log_message(locale, &summary.log_path)); -} - -fn install_next_steps( - locale: Locale, - exe_path: &Path, - codex_home: &Path, - provider_override: Option<&str>, - profile_override: Option<&str>, - manager: ServiceManager, -) -> Result> { - let status_command = - cli_status_command(exe_path, codex_home, provider_override, profile_override); - let log_path = service::log_path()?; - let mut steps = vec![run_status_next_step(locale, &status_command)]; - if let Some(command) = service::current_service_inspect_command()? { - steps.push(inspect_service_next_step(locale, manager, &command)); - } - steps.push(tail_log_next_step( - locale, - &tail_log_command(manager, &log_path), - )); - Ok(steps) -} - -fn cli_status_command( - exe_path: impl AsRef, - codex_home: &Path, - provider_override: Option<&str>, - profile_override: Option<&str>, -) -> String { - let mut parts = vec![ - shell_quote(exe_path.as_ref().display().to_string()), - "--codex-home".to_string(), - shell_quote(codex_home.display().to_string()), - ]; - if let Some(provider) = provider_override { - parts.push("--provider".to_string()); - parts.push(shell_quote(provider.to_string())); - } - if let Some(profile) = profile_override { - parts.push("--profile".to_string()); - parts.push(shell_quote(profile.to_string())); - } - parts.push("status".to_string()); - parts.join(" ") -} - -fn shell_quote(input: String) -> String { - if input - .chars() - .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '/' | '.' | '_' | '-')) - { - return input; - } - format!("'{}'", input.replace('\'', r"'\''")) -} - -fn run_status_next_step(locale: Locale, command: &str) -> String { - match locale { - Locale::En => format!("Run this to verify status: {command}"), - Locale::ZhHans => format!("运行这条命令查看状态:{command}"), - } -} - -fn inspect_service_next_step(locale: Locale, manager: ServiceManager, command: &str) -> String { - let manager_name = service::manager_name(manager); - match locale { - Locale::En => format!("Run this to inspect the {manager_name} service: {command}"), - Locale::ZhHans => format!("运行这条命令查看 {manager_name} 服务:{command}"), - } -} - -fn tail_log_command(manager: ServiceManager, log_path: &Path) -> String { - match manager { - ServiceManager::WindowsStartup => format!( - "powershell -NoProfile -Command \"Get-Content -Path '{}' -Wait\"", - log_path.display() - ), - _ => format!("tail -f {}", log_path.display()), - } -} - -fn tail_log_next_step(locale: Locale, command: &str) -> String { - match locale { - Locale::En => format!("Run this to watch the live log: {command}"), - Locale::ZhHans => format!("运行这条命令查看实时日志:{command}"), - } -} - -fn no_launchd_plist_message(locale: Locale, path: &Path) -> String { - match locale { - Locale::En => format!( - "No background service config is installed at {}.", - path.display() - ), - Locale::ZhHans => format!("{} 这里还没有安装后台服务配置。", path.display()), - } -} - -fn sqlite_missing_error(locale: Locale, path: &Path) -> String { - match locale { - Locale::En => format!( - "database not found at {} — run Codex at least once to create it", - path.display() - ), - Locale::ZhHans => format!( - "未找到数据库 {} — 请先运行一次 Codex 以生成它", - path.display() - ), - } -} - -fn status_title(locale: Locale) -> &'static str { - match locale { - Locale::En => "Codex Threadripper", - Locale::ZhHans => "Codex Threadripper", - } -} - -fn status_codex_home_label(locale: Locale) -> &'static str { - match locale { - Locale::En => "Codex home", - Locale::ZhHans => "Codex home", - } -} - -fn status_config_file_label(locale: Locale) -> &'static str { - match locale { - Locale::En => "Config file", - Locale::ZhHans => "配置文件", - } -} - -fn status_sqlite_file_label(locale: Locale) -> &'static str { - match locale { - Locale::En => "SQLite file", - Locale::ZhHans => "SQLite 文件", - } -} - -fn status_target_provider_label(locale: Locale) -> &'static str { - match locale { - Locale::En => "Target provider", - Locale::ZhHans => "目标 provider", - } -} - -fn status_total_threads_label(locale: Locale) -> &'static str { - match locale { - Locale::En => "Total threads", - Locale::ZhHans => "线程总数", - } -} - -fn status_rows_needing_reconcile_label(locale: Locale) -> &'static str { - match locale { - Locale::En => "Rows needing reconcile", - Locale::ZhHans => "待收敛行数", - } -} - -fn status_distribution_heading(locale: Locale) -> &'static str { - match locale { - Locale::En => "Provider distribution:", - Locale::ZhHans => "Provider 分布:", - } -} - -fn status_background_service_heading(locale: Locale) -> &'static str { - match locale { - Locale::En => "Background service:", - Locale::ZhHans => "后台服务:", - } -} - -fn status_service_manager_label(locale: Locale) -> &'static str { - match locale { - Locale::En => "Manager", - Locale::ZhHans => "管理器", - } -} - -fn status_plist_path_label(locale: Locale) -> &'static str { - match locale { - Locale::En => "Config path", - Locale::ZhHans => "配置路径", - } -} - -fn status_installed_label(locale: Locale) -> &'static str { - match locale { - Locale::En => "Installed", - Locale::ZhHans => "已安装", - } -} - -fn status_loaded_label(locale: Locale) -> &'static str { - match locale { - Locale::En => "Running", - Locale::ZhHans => "运行中", - } -} - -fn sync_rows_updated_label(locale: Locale) -> &'static str { - match locale { - Locale::En => "Rows updated", - Locale::ZhHans => "已更新行数", - } -} - -fn sync_rollouts_checked_label(locale: Locale) -> &'static str { - match locale { - Locale::En => "Rollouts checked", - Locale::ZhHans => "已检查 rollout", - } -} - -fn sync_rollouts_updated_label(locale: Locale) -> &'static str { - match locale { - Locale::En => "Rollouts updated", - Locale::ZhHans => "已更新 rollout", - } -} - -fn sync_rollouts_prepared_label(locale: Locale) -> &'static str { - match locale { - Locale::En => "Rollouts prepared", - Locale::ZhHans => "已准备 rollout", - } -} - -fn sync_rollouts_skipped_label(locale: Locale) -> &'static str { - match locale { - Locale::En => "Rollouts skipped", - Locale::ZhHans => "已跳过 rollout", - } -} - -fn sync_elapsed_label(locale: Locale) -> &'static str { - match locale { - Locale::En => "Elapsed", - Locale::ZhHans => "耗时", - } -} - -fn sync_backup_label(locale: Locale) -> &'static str { - match locale { - Locale::En => "Backup", - Locale::ZhHans => "备份文件", - } -} - -fn sync_rollout_journal_label(locale: Locale) -> &'static str { - match locale { - Locale::En => "Rollout first-line journal", - Locale::ZhHans => "rollout 首行记录", - } -} - -fn service_log_message(locale: Locale, path: &Path) -> String { - match locale { - Locale::En => format!("Log path: {}", path.display()), - Locale::ZhHans => format!("日志路径:{}", path.display()), - } -} - -#[cfg(test)] -mod tests { - use super::APP_VERSION; - use super::Cli; - use super::Command; - use super::DEFAULT_BUCKET_PADDING_BYTES; - use super::DEFAULT_POLL_INTERVAL_MS; - use super::Locale; - use super::RolloutScope; - use super::WATCH_FULL_ROLLOUT_POLL_INTERVALS; - use super::detect_locale_from_sources; - use super::full_watch_rollout_scope; - use super::inspect_sqlite_distribution; - use super::install_next_steps; - use super::localized_command; - use super::parse_apple_languages; - use super::parse_locale_tag; - use super::periodic_watch_rollout_scope; - use super::read_provider_from_config; - use super::reconcile_once; - use super::reconcile_rollout_metadata_from_sqlite_with_progress; - use super::reconcile_sqlite_in_place; - use super::reconcile_sqlite_with_backup; - use super::resolve_sqlite_home_from_config; - use super::resolve_sqlite_path; - use super::validate_provider_override; - use super::validate_provider_override_args; - use super::watched_config_paths; - use crate::service::ServiceManager; - use anyhow::Result; - use clap::FromArgMatches; - use clap::error::ErrorKind; - use filetime::FileTime; - use filetime::set_file_times; - use rusqlite::Connection; - use std::fs; - use std::path::Path; - use std::path::PathBuf; - use std::time::Duration; - - #[test] - fn parses_supported_locale_tags() { - assert_eq!(parse_locale_tag("zh_CN.UTF-8"), Some(Locale::ZhHans)); - assert_eq!(parse_locale_tag("zh-Hans-CN"), Some(Locale::ZhHans)); - assert_eq!(parse_locale_tag("en_US.UTF-8"), Some(Locale::En)); - assert_eq!(parse_locale_tag("en-GB"), Some(Locale::En)); - assert_eq!(parse_locale_tag("ja_JP.UTF-8"), None); - } - - #[test] - fn parses_apple_languages_output() { - let output = "(\n \"zh-Hans-CN\",\n \"en-US\"\n)\n"; - assert_eq!(parse_apple_languages(output), Some(Locale::ZhHans)); - } - - #[test] - fn detects_locale_from_sources() { - let locale = detect_locale_from_sources(["", "zh_CN.UTF-8"], None); - assert_eq!(locale, Locale::ZhHans); - - let locale = detect_locale_from_sources([""], Some("(\n en-US\n)\n")); - assert_eq!(locale, Locale::En); - } - - #[test] - fn localizes_help_surface() { - let mut command = localized_command(Locale::ZhHans); - let mut help = Vec::new(); - command.write_long_help(&mut help).unwrap(); - let rendered = String::from_utf8(help).unwrap(); - assert!(rendered.contains("面向人的 Codex 线程历史维护工具")); - assert!(rendered.contains("用法:")); - assert!(rendered.contains("命令:")); - assert!(rendered.contains("选项:")); - assert!(rendered.contains("安装并启动当前平台上的后台服务")); - assert!(rendered.contains("强制指定 provider,跳过从 config.toml 读取 model_provider")); - assert!(rendered.contains("显示帮助信息")); - assert!(rendered.contains("显示版本号")); - - let mut command = localized_command(Locale::En); - let mut help = Vec::new(); - command.write_long_help(&mut help).unwrap(); - let rendered = String::from_utf8(help).unwrap(); - assert!(rendered.contains("human-first maintenance tool for Codex thread history")); - assert!(rendered.contains("Usage:")); - assert!(rendered.contains("Commands:")); - assert!(rendered.contains("Options:")); - assert!(rendered.contains("Install and start the platform-specific background service")); - assert!( - rendered - .contains("Force a provider instead of reading model_provider from config.toml") - ); - assert!(rendered.contains("Print help")); - assert!(rendered.contains("Print version")); - } - - #[test] - fn subcommand_help_and_version_are_stable() { - let command = localized_command(Locale::En); - let help_error = command - .clone() - .try_get_matches_from(["codex-threadripper", "install-service", "--help"]) - .unwrap_err(); - assert_eq!(help_error.kind(), ErrorKind::DisplayHelp); - - let version_error = command - .try_get_matches_from(["codex-threadripper", "install-service", "--version"]) - .unwrap_err(); - assert_eq!(version_error.kind(), ErrorKind::DisplayVersion); - assert!(version_error.to_string().contains(APP_VERSION)); - } - - #[test] - fn reads_provider_from_config() -> Result<()> { - let dir = tempfile::tempdir()?; - fs::write(dir.path().join("config.toml"), "model_provider = \"vm\"\n")?; - let provider = read_provider_from_config(dir.path(), None)?; - assert_eq!(provider, "vm"); - Ok(()) - } - - #[test] - fn reads_provider_from_profile_v2_config() -> Result<()> { - let dir = tempfile::tempdir()?; - fs::write( - dir.path().join("config.toml"), - "model_provider = \"openai\"\n", - )?; - fs::write( - dir.path().join("work.config.toml"), - "model_provider = \"bedrock\"\nsqlite_home = \"work-state\"\n", - )?; - - let provider = read_provider_from_config(dir.path(), Some("work"))?; - let sqlite_path = resolve_sqlite_path(dir.path(), Some("work"))?; - - assert_eq!(provider, "bedrock"); - assert_eq!(sqlite_path, dir.path().join("work-state/state_5.sqlite")); - Ok(()) - } - - #[test] - fn reads_provider_from_selected_legacy_profile() -> Result<()> { - let dir = tempfile::tempdir()?; - fs::write( - dir.path().join("config.toml"), - r#" -model_provider = "openai" -profile = "work" - -[profiles.work] -model_provider = "local" -"#, - )?; - - let provider = read_provider_from_config(dir.path(), None)?; - - assert_eq!(provider, "local"); - Ok(()) - } - - #[test] - fn errors_when_profile_config_is_missing() -> Result<()> { - let dir = tempfile::tempdir()?; - fs::write( - dir.path().join("config.toml"), - "model_provider = \"openai\"\n", - )?; - - let err = read_provider_from_config(dir.path(), Some("missing")).unwrap_err(); - - assert!(err.to_string().contains("profile `missing` was not found")); - Ok(()) - } - - #[test] - fn falls_back_to_root_config_when_selected_profile_is_missing() -> Result<()> { - let dir = tempfile::tempdir()?; - fs::write( - dir.path().join("config.toml"), - "model_provider = \"openai\"\nsqlite_home = \"root-state\"\nprofile = \"missing\"\n", - )?; - - let provider = read_provider_from_config(dir.path(), None)?; - let sqlite_path = resolve_sqlite_path(dir.path(), None)?; - - assert_eq!(provider, "openai"); - assert_eq!(sqlite_path, dir.path().join("root-state/state_5.sqlite")); - Ok(()) - } - - #[test] - fn defaults_to_openai_without_config() -> Result<()> { - let dir = tempfile::tempdir()?; - let provider = read_provider_from_config(dir.path(), None)?; - assert_eq!(provider, "openai"); - Ok(()) - } - - #[test] - fn defaults_to_openai_with_blank_provider_in_config() -> Result<()> { - let dir = tempfile::tempdir()?; - fs::write(dir.path().join("config.toml"), "model_provider = \" \"\n")?; - let provider = read_provider_from_config(dir.path(), None)?; - assert_eq!(provider, "openai"); - Ok(()) - } - - #[test] - fn resolves_sqlite_path_from_config_sqlite_home() -> Result<()> { - let dir = tempfile::tempdir()?; - let sqlite_home = dir.path().join("custom-state"); - fs::write( - dir.path().join("config.toml"), - sqlite_home_config(&sqlite_home), - )?; - - let sqlite_path = resolve_sqlite_path(dir.path(), None)?; - - assert_eq!(sqlite_path, sqlite_home.join("state_5.sqlite")); - Ok(()) - } - - #[test] - fn resolves_sqlite_home_env_after_config() -> Result<()> { - let dir = tempfile::tempdir()?; - let current_dir = dir.path().join("project"); - - let config_wins = resolve_sqlite_home_from_config( - dir.path(), - Some("configured-state"), - Some("env-state"), - current_dir.as_path(), - ); - let env_fallback = resolve_sqlite_home_from_config( - dir.path(), - Some(" "), - Some("env-state"), - current_dir.as_path(), - ); - let default_home = - resolve_sqlite_home_from_config(dir.path(), None, None, current_dir.as_path()); - - assert_eq!(config_wins, dir.path().join("configured-state")); - assert_eq!(env_fallback, current_dir.join("env-state")); - assert_eq!(default_home, dir.path()); - Ok(()) - } - - #[test] - fn rejects_blank_provider_override() { - let err = validate_provider_override(Locale::En, Some(" ")).unwrap_err(); - assert!(err.to_string().contains("provider must contain")); - } - - #[test] - fn rejects_blank_provider_override_in_cli_args_before_help_short_circuit() { - let err = validate_provider_override_args( - Locale::En, - ["codex-threadripper", "--provider", "", "status", "--help"], - ) - .unwrap_err(); - assert!(err.to_string().contains("provider must contain")); - } - - #[test] - fn rejects_blank_profile_override_in_cli_args_before_help_short_circuit() { - let err = validate_provider_override_args( - Locale::En, - ["codex-threadripper", "--profile", "", "status", "--help"], - ) - .unwrap_err(); - assert!(err.to_string().contains("profile must contain")); - } - - #[test] - fn rejects_path_profile_override_in_cli_args_before_help_short_circuit() { - let err = validate_provider_override_args( - Locale::En, - [ - "codex-threadripper", - "--profile", - "nested/work", - "status", - "--help", - ], - ) - .unwrap_err(); - assert!(err.to_string().contains("profile must be a plain name")); - } - - #[test] - fn reconcile_once_returns_error_when_db_missing() -> Result<()> { - let dir = tempfile::tempdir()?; - let sqlite_path = dir.path().join("state_5.sqlite"); - fs::write( - dir.path().join("config.toml"), - sqlite_home_config(dir.path()), - )?; - - let err = reconcile_once(dir.path(), Some("openai"), None, RolloutScope::None).unwrap_err(); - - assert!(err.to_string().contains(&sqlite_path.display().to_string())); - assert!(!sqlite_path.exists()); - Ok(()) - } - - #[test] - fn inspect_sqlite_distribution_returns_error_when_db_missing() -> Result<()> { - let dir = tempfile::tempdir()?; - let sqlite_path = dir.path().join("state_5.sqlite"); - let backups_path = dir.path().join("backups"); - - let err = inspect_sqlite_distribution(&sqlite_path, "openai").unwrap_err(); - - assert!(err.to_string().contains(&sqlite_path.display().to_string())); - assert!(!sqlite_path.exists()); - assert!(!backups_path.exists()); - Ok(()) - } - - #[test] - fn reconciles_sqlite_rows_in_place() -> Result<()> { - let dir = tempfile::tempdir()?; - let sqlite_path = dir.path().join("state_5.sqlite"); - seed_sqlite(&sqlite_path)?; - - let (changed_rows, total_rows) = reconcile_sqlite_in_place(&sqlite_path, "openai")?; - let connection = Connection::open(&sqlite_path)?; - let other_rows: u64 = connection.query_row( - "SELECT COUNT(*) FROM threads WHERE model_provider <> 'openai'", - [], - |row| row.get(0), - )?; - - assert_eq!(changed_rows, 2); - assert_eq!(total_rows, 3); - assert_eq!(other_rows, 0); - Ok(()) - } - - #[test] - fn sync_backup_preserves_pre_reconcile_state() -> Result<()> { - let dir = tempfile::tempdir()?; - let sqlite_path = dir.path().join("state_5.sqlite"); - seed_sqlite(&sqlite_path)?; - - let (changed_rows, total_rows, backup_path) = - reconcile_sqlite_with_backup(&sqlite_path, "openai")?; - let live_connection = Connection::open(&sqlite_path)?; - let backup_connection = Connection::open(&backup_path)?; - let live_other_rows: u64 = live_connection.query_row( - "SELECT COUNT(*) FROM threads WHERE model_provider <> 'openai'", - [], - |row| row.get(0), - )?; - let backup_other_rows: u64 = backup_connection.query_row( - "SELECT COUNT(*) FROM threads WHERE model_provider <> 'openai'", - [], - |row| row.get(0), - )?; - - assert_eq!(changed_rows, 2); - assert_eq!(total_rows, 3); - assert_eq!(live_other_rows, 0); - assert_eq!(backup_other_rows, 2); - Ok(()) - } - - #[test] - fn seed_sqlite_fixture_includes_current_codex_thread_columns() -> Result<()> { - let dir = tempfile::tempdir()?; - let sqlite_path = dir.path().join("state_5.sqlite"); - seed_sqlite(&sqlite_path)?; - let connection = Connection::open(&sqlite_path)?; - - let mut statement = connection.prepare("PRAGMA table_info(threads)")?; - let columns = statement - .query_map([], |row| row.get::<_, String>(1))? - .collect::>>()?; - for column in ["created_at_ms", "updated_at_ms", "thread_source", "preview"] { - assert!(columns.iter().any(|candidate| candidate == column)); - } - let mut statement = connection.prepare("PRAGMA index_list(threads)")?; - let indexes = statement - .query_map([], |row| row.get::<_, String>(1))? - .collect::>>()?; - for index in [ - "idx_threads_created_at_ms", - "idx_threads_updated_at_ms", - "idx_threads_archived_cwd_created_at_ms", - "idx_threads_archived_cwd_updated_at_ms", - ] { - assert!(indexes.iter().any(|candidate| candidate == index)); - } - let mut statement = - connection.prepare("SELECT name FROM sqlite_master WHERE type = 'trigger'")?; - let triggers = statement - .query_map([], |row| row.get::<_, String>(0))? - .collect::>>()?; - for trigger in [ - "threads_created_at_ms_after_insert", - "threads_updated_at_ms_after_insert", - "threads_created_at_ms_after_update", - "threads_updated_at_ms_after_update", - ] { - assert!(triggers.iter().any(|candidate| candidate == trigger)); - } - - let (created_at_ms, updated_at_ms, thread_source, preview): ( - i64, - i64, - String, - String, - ) = connection.query_row( - "SELECT created_at_ms, updated_at_ms, thread_source, preview FROM threads WHERE id = '1'", - [], - |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)), - )?; - assert_eq!((created_at_ms, updated_at_ms), (1000, 1000)); - assert_eq!(thread_source, "user"); - assert_eq!(preview, "a"); - - connection.execute( - " - INSERT INTO threads ( - id, rollout_path, created_at, updated_at, source, thread_source, - model_provider, cwd, title, sandbox_policy, approval_mode, cli_version, - model, reasoning_effort, first_user_message, preview - ) VALUES ( - 'trigger-test', '/tmp/trigger-test', 2, 3, 'cli', 'user', - 'openai', '/tmp', 'trigger-test', 'workspace-write', 'auto', - '0.0.0-test', 'gpt-5-codex', 'medium', 'trigger-test', 'trigger-test' - ) - ", - [], - )?; - let trigger_ms: (i64, i64) = connection.query_row( - "SELECT created_at_ms, updated_at_ms FROM threads WHERE id = 'trigger-test'", - [], - |row| Ok((row.get(0)?, row.get(1)?)), - )?; - assert_eq!(trigger_ms, (2000, 3000)); - Ok(()) - } - - #[test] - fn durable_sync_updates_matching_rollout_session_meta() -> Result<()> { - let dir = tempfile::tempdir()?; - let codex_home = dir.path(); - let sqlite_path = codex_home.join("state_5.sqlite"); - let rollout_path = - codex_home.join("sessions/2026/05/07/rollout-2026-05-07T14-06-18-1.jsonl"); - fs::create_dir_all(rollout_path.parent().unwrap())?; - fs::write( - &rollout_path, - concat!( - "{\"type\":\"session_meta\",\"payload\":{\"id\":\"1\",\"model_provider\":\"cong\"}}\n", - "{\"type\":\"session_meta\",\"payload\":{\"id\":\"other\",\"model_provider\":\"cong\"}}\n", - "{\"type\":\"event_msg\",\"payload\":{\"type\":\"user_message\",\"message\":\"hi\"}}\n", - ), - )?; - let original_mtime = FileTime::from_unix_time(1_700_000_000, 0); - set_file_times(&rollout_path, original_mtime, original_mtime)?; - seed_sqlite(&sqlite_path)?; - let connection = Connection::open(&sqlite_path)?; - connection.execute( - "UPDATE threads SET rollout_path = ?1, model_provider = 'openai' WHERE id = '1'", - [rollout_path.display().to_string()], - )?; - drop(connection); - - let journal_path = codex_home.join("backups/rollouts.test.jsonl"); - let summary = reconcile_rollout_metadata_from_sqlite_with_progress( - &sqlite_path, - codex_home, - "openai", - RolloutScope::AllRows, - Some(journal_path.as_path()), - DEFAULT_BUCKET_PADDING_BYTES, - None, - )?; - - let rewritten = fs::read_to_string(&rollout_path)?; - let journal = fs::read_to_string(&journal_path)?; - assert_eq!(summary.checked_files, 1); - assert_eq!(summary.changed_files, 1); - assert!(rewritten.contains("\"id\":\"1\"")); - assert!(rewritten.contains("\"model_provider\":\"openai\"")); - assert!(rewritten.contains("\"id\":\"other\"")); - assert!(rewritten.contains("\"model_provider\":\"cong\"")); - assert!(journal.contains("\"mode\":\"rewrite_with_padding\"")); - assert!(journal.contains("cong")); - assert_rollout_mtime(&rollout_path, original_mtime)?; - Ok(()) - } - - #[test] - fn durable_sync_patches_shorter_provider_in_place() -> Result<()> { - let dir = tempfile::tempdir()?; - let codex_home = dir.path(); - let sqlite_path = codex_home.join("state_5.sqlite"); - let rollout_path = - codex_home.join("sessions/2026/05/07/rollout-2026-05-07T14-06-18-1.jsonl"); - fs::create_dir_all(rollout_path.parent().unwrap())?; - fs::write( - &rollout_path, - concat!( - "{\"type\":\"session_meta\",\"payload\":{\"id\":\"1\",\"model_provider\":\"openai\"}} \n", - "{\"type\":\"event_msg\",\"payload\":{\"type\":\"user_message\",\"message\":\"hi\"}}\n", - ), - )?; - let original_mtime = FileTime::from_unix_time(1_700_000_100, 0); - set_file_times(&rollout_path, original_mtime, original_mtime)?; - seed_sqlite(&sqlite_path)?; - let connection = Connection::open(&sqlite_path)?; - connection.execute( - "UPDATE threads SET rollout_path = ?1, model_provider = 'openai' WHERE id = '1'", - [rollout_path.display().to_string()], - )?; - drop(connection); - let before_len = fs::metadata(&rollout_path)?.len(); - - let journal_path = codex_home.join("backups/rollouts.in-place.jsonl"); - let summary = reconcile_rollout_metadata_from_sqlite_with_progress( - &sqlite_path, - codex_home, - "cong", - RolloutScope::AllRows, - Some(journal_path.as_path()), - DEFAULT_BUCKET_PADDING_BYTES, - None, - )?; - - let after_len = fs::metadata(&rollout_path)?.len(); - let rewritten = fs::read_to_string(&rollout_path)?; - let journal = fs::read_to_string(&journal_path)?; - assert_eq!(summary.checked_files, 1); - assert_eq!(summary.changed_files, 1); - assert_eq!(summary.prepared_files, 0); - assert_eq!(before_len, after_len); - assert!( - rewritten - .lines() - .next() - .unwrap() - .contains("\"model_provider\":\"cong\"") - ); - assert!(journal.contains("\"mode\":\"in_place\"")); - assert_rollout_mtime(&rollout_path, original_mtime)?; - Ok(()) - } - - #[test] - fn watch_uses_five_hundred_ms_by_default() -> Result<()> { - let matches = - localized_command(Locale::En).try_get_matches_from(["codex-threadripper", "watch"])?; - let cli = Cli::from_arg_matches(&matches)?; - - match cli.command { - Command::Watch { - poll_interval_ms, .. - } => { - assert_eq!(poll_interval_ms, DEFAULT_POLL_INTERVAL_MS); - } - other => panic!("expected watch command, got {other:?}"), - } - - Ok(()) - } - - #[test] - fn watch_promotes_initial_rollout_scan_to_all_rows() { - assert_eq!( - full_watch_rollout_scope(RolloutScope::MismatchedRows), - RolloutScope::AllRows - ); - assert_eq!( - full_watch_rollout_scope(RolloutScope::None), - RolloutScope::None - ); - } - - #[test] - fn watch_periodically_runs_full_rollout_scan() { - assert_eq!( - periodic_watch_rollout_scope(RolloutScope::MismatchedRows, 1), - RolloutScope::MismatchedRows - ); - assert_eq!( - periodic_watch_rollout_scope( - RolloutScope::MismatchedRows, - WATCH_FULL_ROLLOUT_POLL_INTERVALS - ), - RolloutScope::AllRows - ); - assert_eq!( - periodic_watch_rollout_scope(RolloutScope::None, WATCH_FULL_ROLLOUT_POLL_INTERVALS), - RolloutScope::None - ); - } - - #[test] - fn watch_tracks_selected_profile_config_file() -> Result<()> { - let dir = tempfile::tempdir()?; - fs::write( - dir.path().join("config.toml"), - "model_provider = \"openai\"\nprofile = \"work\"\n", - )?; - - let implicit = watched_config_paths(dir.path(), None); - let explicit = watched_config_paths(dir.path(), Some("other")); - - assert!(implicit.contains(&dir.path().join("config.toml"))); - assert!(implicit.contains(&dir.path().join("work.config.toml"))); - assert!(explicit.contains(&dir.path().join("other.config.toml"))); - Ok(()) - } - - #[test] - fn generates_launchd_plist() { - let plist = crate::service::build_launchd_plist( - PathBuf::from("/tmp/codex-threadripper").as_path(), - PathBuf::from("/tmp/codex-home").as_path(), - Some("openai"), - Some("work"), - Duration::from_millis(DEFAULT_POLL_INTERVAL_MS), - ); - assert!(plist.contains("dev.wangnov.codex-threadripper")); - assert!(plist.contains("--codex-home")); - assert!(plist.contains("--provider")); - assert!(plist.contains("--profile")); - assert!(plist.contains("watch")); - } - - #[test] - fn builds_install_next_steps() -> Result<()> { - let manager = crate::service::current_manager(); - let steps = install_next_steps( - Locale::ZhHans, - PathBuf::from("/tmp/codex threadripper").as_path(), - PathBuf::from("/tmp/codex home").as_path(), - Some("openai"), - Some("work"), - manager, - )?; - assert_eq!(steps.len(), 3); - assert!(steps[0].contains("运行这条命令查看状态")); - assert!( - steps[0].contains( - "'/tmp/codex threadripper' --codex-home '/tmp/codex home' --provider openai --profile work status" - ) - ); - assert!(steps[1].contains(crate::service::manager_name(manager))); - match manager { - ServiceManager::WindowsStartup => assert!(steps[2].contains("Get-Content")), - _ => assert!(steps[2].contains("tail -f")), - } - Ok(()) - } - - fn seed_sqlite(path: &Path) -> Result<()> { - let connection = Connection::open(path)?; - connection.execute_batch( - " - CREATE TABLE threads ( - id TEXT PRIMARY KEY, - rollout_path TEXT NOT NULL, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL, - created_at_ms INTEGER, - updated_at_ms INTEGER, - source TEXT NOT NULL, - thread_source TEXT, - model_provider TEXT NOT NULL, - cwd TEXT NOT NULL, - title TEXT NOT NULL, - sandbox_policy TEXT NOT NULL, - approval_mode TEXT NOT NULL, - tokens_used INTEGER NOT NULL DEFAULT 0, - has_user_event INTEGER NOT NULL DEFAULT 0, - archived INTEGER NOT NULL DEFAULT 0, - archived_at INTEGER, - git_sha TEXT, - git_branch TEXT, - git_origin_url TEXT, - cli_version TEXT NOT NULL DEFAULT '', - first_user_message TEXT NOT NULL DEFAULT '', - agent_nickname TEXT, - agent_role TEXT, - memory_mode TEXT NOT NULL DEFAULT 'enabled', - model TEXT, - reasoning_effort TEXT, - agent_path TEXT, - preview TEXT NOT NULL DEFAULT '' - ); - CREATE INDEX idx_threads_created_at ON threads(created_at DESC, id DESC); - CREATE INDEX idx_threads_updated_at ON threads(updated_at DESC, id DESC); - CREATE INDEX idx_threads_archived ON threads(archived); - CREATE INDEX idx_threads_source ON threads(source); - CREATE INDEX idx_threads_provider ON threads(model_provider); - CREATE INDEX idx_threads_created_at_ms ON threads(created_at_ms DESC, id DESC); - CREATE INDEX idx_threads_updated_at_ms ON threads(updated_at_ms DESC, id DESC); - CREATE INDEX idx_threads_archived_cwd_created_at_ms - ON threads(archived, cwd, created_at_ms DESC, id DESC); - CREATE INDEX idx_threads_archived_cwd_updated_at_ms - ON threads(archived, cwd, updated_at_ms DESC, id DESC); - CREATE TRIGGER threads_created_at_ms_after_insert - AFTER INSERT ON threads - WHEN NEW.created_at_ms IS NULL - BEGIN - UPDATE threads - SET created_at_ms = NEW.created_at * 1000 - WHERE id = NEW.id; - END; - CREATE TRIGGER threads_updated_at_ms_after_insert - AFTER INSERT ON threads - WHEN NEW.updated_at_ms IS NULL - BEGIN - UPDATE threads - SET updated_at_ms = NEW.updated_at * 1000 - WHERE id = NEW.id; - END; - CREATE TRIGGER threads_created_at_ms_after_update - AFTER UPDATE OF created_at ON threads - WHEN NEW.created_at != OLD.created_at - AND NEW.created_at_ms IS OLD.created_at_ms - BEGIN - UPDATE threads - SET created_at_ms = NEW.created_at * 1000 - WHERE id = NEW.id; - END; - CREATE TRIGGER threads_updated_at_ms_after_update - AFTER UPDATE OF updated_at ON threads - WHEN NEW.updated_at != OLD.updated_at - AND NEW.updated_at_ms IS OLD.updated_at_ms - BEGIN - UPDATE threads - SET updated_at_ms = NEW.updated_at * 1000 - WHERE id = NEW.id; - END; - INSERT INTO threads ( - id, rollout_path, created_at, updated_at, created_at_ms, updated_at_ms, - source, thread_source, model_provider, cwd, title, sandbox_policy, - approval_mode, cli_version, model, reasoning_effort, first_user_message, preview - ) VALUES - ('1', '/tmp/a', 1, 1, 1000, 1000, 'cli', 'user', 'vm', '/tmp', 'a', - 'workspace-write', 'auto', '0.0.0-test', 'gpt-5-codex', 'medium', 'a', 'a'), - ('2', '/tmp/b', 1, 1, 1001, 1001, 'cli', 'user', 'cp', '/tmp', 'b', - 'workspace-write', 'auto', '0.0.0-test', 'gpt-5-codex', 'medium', 'b', 'b'), - ('3', '/tmp/c', 1, 1, 1002, 1002, 'cli', 'user', 'openai', '/tmp', 'c', - 'workspace-write', 'auto', '0.0.0-test', 'gpt-5-codex', 'medium', 'c', 'c'); - ", - )?; - Ok(()) - } - - fn sqlite_home_config(path: &Path) -> String { - let path = path - .display() - .to_string() - .replace('\\', "\\\\") - .replace('"', "\\\""); - format!("sqlite_home = \"{path}\"\n") - } - - fn assert_rollout_mtime(path: &Path, expected: FileTime) -> Result<()> { - let actual = FileTime::from_last_modification_time(&fs::metadata(path)?); - assert_eq!(actual.unix_seconds(), expected.unix_seconds()); - Ok(()) - } -} diff --git a/src/output.rs b/src/output.rs new file mode 100644 index 0000000..9237537 --- /dev/null +++ b/src/output.rs @@ -0,0 +1,888 @@ +use anyhow::Result; +use std::path::Path; +use std::time::Duration; + +use crate::locale::Locale; +use crate::rollout::BucketPrepareSummary; +use crate::service; +use crate::service::ServiceInstallSummary; +use crate::service::ServiceManager; +use crate::sync::ReconcileSummary; +use crate::sync::StatusSummary; + +#[derive(Clone, Copy, Debug)] +pub(crate) struct RolloutProgressSnapshot { + pub(crate) visited_files: u64, + pub(crate) total_files: u64, + pub(crate) checked_files: u64, + pub(crate) changed_files: u64, + pub(crate) prepared_files: u64, + pub(crate) skipped_files: u64, + pub(crate) elapsed: Duration, +} + +pub(crate) fn print_status(locale: Locale, summary: &StatusSummary) { + println!("{}", status_title(locale)); + println!(); + println!( + "{}: {}", + status_codex_home_label(locale), + summary.codex_home.display() + ); + println!( + "{}: {}", + status_config_file_label(locale), + summary.config_path.display() + ); + println!( + "{}: {}", + status_sqlite_file_label(locale), + summary.sqlite_path.display() + ); + println!( + "{}: {}", + status_target_provider_label(locale), + summary.provider + ); + println!( + "{}: {}", + status_total_threads_label(locale), + summary.total_rows + ); + println!( + "{}: {}", + status_rows_needing_reconcile_label(locale), + summary.mismatched_rows + ); + println!(); + println!("{}", status_distribution_heading(locale)); + for (provider, count) in &summary.distribution { + println!(" {provider}: {count}"); + } + println!(); + println!("{}", status_background_service_heading(locale)); + println!( + " {}: {}", + status_service_manager_label(locale), + service::manager_name(summary.service_status.manager) + ); + println!( + " {}: {}", + status_plist_path_label(locale), + summary.service_status.config_path.display() + ); + println!( + " {}: {}", + status_installed_label(locale), + yes_no(locale, summary.service_status.installed) + ); + println!( + " {}: {}", + status_loaded_label(locale), + yes_no(locale, summary.service_status.running) + ); +} + +pub(crate) fn rollout_progress_message( + locale: Locale, + snapshot: &RolloutProgressSnapshot, +) -> String { + let RolloutProgressSnapshot { + visited_files, + total_files, + checked_files, + changed_files, + prepared_files, + skipped_files, + elapsed, + } = *snapshot; + match locale { + Locale::En => format!( + "Rollouts: scanned {visited_files}/{total_files}, checked {checked_files}, updated {changed_files}, prepared {prepared_files}, skipped {skipped_files}, elapsed {} ms", + elapsed.as_millis() + ), + Locale::ZhHans => format!( + "rollout 进度: 已扫描 {visited_files}/{total_files},已检查 {checked_files},已更新 {changed_files},已准备 {prepared_files},已跳过 {skipped_files},耗时 {} ms", + elapsed.as_millis() + ), + } +} + +pub(crate) fn print_sync_summary(locale: Locale, title: &str, summary: &ReconcileSummary) { + println!("{title}"); + println!( + "{}: {}", + status_target_provider_label(locale), + summary.provider + ); + println!( + "{}: {}", + sync_rows_updated_label(locale), + summary.changed_rows + ); + if summary.checked_rollouts > 0 || summary.changed_rollouts > 0 { + println!( + "{}: {}", + sync_rollouts_checked_label(locale), + summary.checked_rollouts + ); + println!( + "{}: {}", + sync_rollouts_updated_label(locale), + summary.changed_rollouts + ); + if summary.prepared_rollouts > 0 { + println!( + "{}: {}", + sync_rollouts_prepared_label(locale), + summary.prepared_rollouts + ); + } + if summary.skipped_rollouts > 0 { + println!( + "{}: {}", + sync_rollouts_skipped_label(locale), + summary.skipped_rollouts + ); + } + } + println!( + "{}: {}", + status_total_threads_label(locale), + summary.total_rows + ); + println!( + "{}: {} ms", + sync_elapsed_label(locale), + summary.elapsed.as_millis() + ); + if let Some(backup_path) = &summary.backup_path { + println!("{}: {}", sync_backup_label(locale), backup_path.display()); + } + if let Some(journal_path) = &summary.rollout_journal_path { + println!( + "{}: {}", + sync_rollout_journal_label(locale), + journal_path.display() + ); + } +} + +pub(crate) fn print_bucket_prepare_summary(locale: Locale, summary: &BucketPrepareSummary) { + println!("{}", bucket_prepare_complete_title(locale)); + println!( + "{}: {}", + sync_rollouts_checked_label(locale), + summary.checked_rollouts + ); + println!( + "{}: {}", + sync_rollouts_prepared_label(locale), + summary.prepared_rollouts + ); + if summary.skipped_rollouts > 0 { + println!( + "{}: {}", + sync_rollouts_skipped_label(locale), + summary.skipped_rollouts + ); + } + println!( + "{}: {} ms", + sync_elapsed_label(locale), + summary.elapsed.as_millis() + ); + if let Some(journal_path) = &summary.journal_path { + println!( + "{}: {}", + sync_rollout_journal_label(locale), + journal_path.display() + ); + } +} + +pub(crate) fn yes_no(locale: Locale, value: bool) -> &'static str { + match (locale, value) { + (Locale::En, true) => "yes", + (Locale::En, false) => "no", + (Locale::ZhHans, true) => "是", + (Locale::ZhHans, false) => "否", + } +} + +pub(crate) fn root_about(locale: Locale) -> &'static str { + match locale { + Locale::En => "Keep Codex's SQLite state DB aligned with the current model_provider.", + Locale::ZhHans => "让 Codex 的 SQLite 状态库持续对齐当前 model_provider。", + } +} + +pub(crate) fn root_long_about(locale: Locale) -> &'static str { + match locale { + Locale::En => { + "codex-threadripper is a human-first maintenance tool for Codex thread history.\n\nIt reads the effective Codex provider from CODEX_HOME/config.toml, including the selected profile config when present, and rewrites Codex's SQLite state DB so every thread stays in the same provider bucket. The DB defaults to CODEX_HOME/state_5.sqlite, but sqlite_home and CODEX_SQLITE_HOME are respected. That makes thread lists and resume flows stop fragmenting across providers.\n\nExamples:\n codex-threadripper status\n codex-threadripper sync\n codex-threadripper watch\n codex-threadripper install-service" + } + Locale::ZhHans => { + "codex-threadripper 是一个面向人的 Codex 线程历史维护工具。\n\n它会读取 CODEX_HOME/config.toml 和已选 profile 配置合成后的有效 provider,并改写 Codex 的 SQLite 状态库,让所有线程始终落在同一个 provider 桶里。状态库默认是 CODEX_HOME/state_5.sqlite,同时会尊重 sqlite_home 和 CODEX_SQLITE_HOME。这样线程列表和 resume 流程就不会再被 provider 切碎。\n\n示例:\n codex-threadripper status\n codex-threadripper sync\n codex-threadripper watch\n codex-threadripper install-service" + } + } +} + +pub(crate) fn help_template(locale: Locale) -> &'static str { + match locale { + Locale::En => "{before-help}{about-section}Usage: {usage}\n\n{all-args}{after-help}", + Locale::ZhHans => "{before-help}{about-section}用法:{usage}\n\n{all-args}{after-help}", + } +} + +pub(crate) fn commands_heading(locale: Locale) -> &'static str { + match locale { + Locale::En => "Commands", + Locale::ZhHans => "命令", + } +} + +pub(crate) fn options_heading(locale: Locale) -> &'static str { + match locale { + Locale::En => "Options", + Locale::ZhHans => "选项", + } +} + +pub(crate) fn dir_value_name(locale: Locale) -> &'static str { + match locale { + Locale::En => "DIR", + Locale::ZhHans => "目录", + } +} + +pub(crate) fn provider_value_name(locale: Locale) -> &'static str { + match locale { + Locale::En => "PROVIDER", + Locale::ZhHans => "PROVIDER", + } +} + +pub(crate) fn profile_value_name(locale: Locale) -> &'static str { + match locale { + Locale::En => "PROFILE", + Locale::ZhHans => "PROFILE", + } +} + +pub(crate) fn milliseconds_value_name(locale: Locale) -> &'static str { + match locale { + Locale::En => "MILLISECONDS", + Locale::ZhHans => "毫秒", + } +} + +pub(crate) fn bytes_value_name(locale: Locale) -> &'static str { + match locale { + Locale::En => "BYTES", + Locale::ZhHans => "字节", + } +} + +pub(crate) fn codex_home_help(locale: Locale) -> &'static str { + match locale { + Locale::En => "Codex home directory. Defaults to $HOME/.codex.", + Locale::ZhHans => "Codex home 目录。默认值是 $HOME/.codex。", + } +} + +pub(crate) fn provider_help(locale: Locale) -> &'static str { + match locale { + Locale::En => "Force a provider instead of reading model_provider from config.toml.", + Locale::ZhHans => "强制指定 provider,跳过从 config.toml 读取 model_provider。", + } +} + +pub(crate) fn profile_help(locale: Locale) -> &'static str { + match locale { + Locale::En => { + "Resolve model_provider and sqlite_home from a Codex profile config first. Profile names may contain ASCII letters, digits, '_' or '-'." + } + Locale::ZhHans => { + "优先从 Codex profile 配置解析 model_provider 和 sqlite_home。profile 名称可包含 ASCII 字母、数字、'_' 或 '-'。" + } + } +} + +pub(crate) fn provider_empty_error(locale: Locale) -> &'static str { + match locale { + Locale::En => "provider must contain at least one non-whitespace character", + Locale::ZhHans => "provider 需要包含至少一个非空白字符", + } +} + +pub(crate) fn profile_empty_error(locale: Locale) -> &'static str { + match locale { + Locale::En => "profile must contain at least one non-whitespace character", + Locale::ZhHans => "profile 需要包含至少一个非空白字符", + } +} + +pub(crate) fn profile_path_error(locale: Locale) -> &'static str { + match locale { + Locale::En => { + "profile must be a plain name containing only ASCII letters, digits, '_' or '-'" + } + Locale::ZhHans => "profile 需要是只包含 ASCII 字母、数字、'_' 或 '-' 的普通名称", + } +} + +pub(crate) fn help_option_help(locale: Locale) -> &'static str { + match locale { + Locale::En => "Print help", + Locale::ZhHans => "显示帮助信息", + } +} + +pub(crate) fn version_option_help(locale: Locale) -> &'static str { + match locale { + Locale::En => "Print version", + Locale::ZhHans => "显示版本号", + } +} + +pub(crate) fn status_about(locale: Locale) -> &'static str { + match locale { + Locale::En => { + "Show the current config provider, SQLite distribution, and background service state." + } + Locale::ZhHans => "显示当前 config provider、SQLite 分布和后台服务状态。", + } +} + +pub(crate) fn sync_about(locale: Locale) -> &'static str { + match locale { + Locale::En => "Reconcile Codex's SQLite state DB and rollout metadata once right now.", + Locale::ZhHans => "立刻收敛 Codex SQLite 状态库和 rollout 元数据。", + } +} + +pub(crate) fn bucket_about(locale: Locale) -> &'static str { + match locale { + Locale::En => "Manage the provider bucket label stored in rollout first lines.", + Locale::ZhHans => "管理 rollout 首行里的 provider 可见桶标签。", + } +} + +pub(crate) fn bucket_prepare_about(locale: Locale) -> &'static str { + match locale { + Locale::En => "Reserve first-line padding so future provider bucket switches stay fast.", + Locale::ZhHans => "给首行预留 padding,让后续 provider 桶切换保持快速。", + } +} + +pub(crate) fn bucket_switch_about(locale: Locale) -> &'static str { + match locale { + Locale::En => "Switch every thread into one provider bucket.", + Locale::ZhHans => "把所有线程切到同一个 provider 可见桶。", + } +} + +pub(crate) fn bucket_switch_provider_help(locale: Locale) -> &'static str { + match locale { + Locale::En => "Target provider bucket. Defaults to --provider or config.toml.", + Locale::ZhHans => "目标 provider 桶。默认读取 --provider 或 config.toml。", + } +} + +pub(crate) fn padding_bytes_help(locale: Locale) -> &'static str { + match locale { + Locale::En => "Extra JSON whitespace to reserve at the end of the rollout first line.", + Locale::ZhHans => "在 rollout 首行末尾预留的额外 JSON 空白字节数。", + } +} + +pub(crate) fn watch_about(locale: Locale) -> &'static str { + match locale { + Locale::En => { + "Keep watching config.toml and keep reconciling new rows in SQLite and rollout metadata." + } + Locale::ZhHans => "持续监听 config.toml,并持续收敛 SQLite 与 rollout 新增元数据。", + } +} + +pub(crate) fn poll_interval_help(locale: Locale) -> &'static str { + match locale { + Locale::En => "How often to reconcile SQLite while watching.", + Locale::ZhHans => "watch 模式下,定时收敛 SQLite 的频率。", + } +} + +pub(crate) fn sqlite_only_help(locale: Locale) -> &'static str { + match locale { + Locale::En => { + "Only update Codex's SQLite state DB; leave rollout JSONL metadata untouched." + } + Locale::ZhHans => "只更新 Codex SQLite 状态库,不改 rollout JSONL 元数据。", + } +} + +pub(crate) fn print_plist_about(locale: Locale) -> &'static str { + match locale { + Locale::En => "Print the platform-specific background service config for this tool.", + Locale::ZhHans => "打印这个工具在当前平台上的后台服务配置。", + } +} + +pub(crate) fn install_launchd_about(locale: Locale) -> &'static str { + match locale { + Locale::En => "Install and start the platform-specific background service.", + Locale::ZhHans => "安装并启动当前平台上的后台服务。", + } +} + +pub(crate) fn uninstall_launchd_about(locale: Locale) -> &'static str { + match locale { + Locale::En => "Stop and remove the platform-specific background service.", + Locale::ZhHans => "停止并移除当前平台上的后台服务。", + } +} + +pub(crate) fn launchd_poll_help(locale: Locale) -> &'static str { + match locale { + Locale::En => "Polling interval for the background watcher.", + Locale::ZhHans => "后台 watcher 的轮询间隔。", + } +} + +pub(crate) fn current_exe_error(locale: Locale) -> &'static str { + match locale { + Locale::En => "failed to resolve current executable", + Locale::ZhHans => "无法解析当前可执行文件路径", + } +} + +pub(crate) fn sync_complete_title(locale: Locale) -> &'static str { + match locale { + Locale::En => "Sync complete", + Locale::ZhHans => "同步完成", + } +} + +pub(crate) fn bucket_switch_complete_title(locale: Locale) -> &'static str { + match locale { + Locale::En => "Bucket switch complete", + Locale::ZhHans => "可见桶切换完成", + } +} + +pub(crate) fn bucket_prepare_complete_title(locale: Locale) -> &'static str { + match locale { + Locale::En => "Bucket prepare complete", + Locale::ZhHans => "可见桶准备完成", + } +} + +pub(crate) fn watch_started_title(locale: Locale) -> &'static str { + match locale { + Locale::En => "Watch started", + Locale::ZhHans => "已开始监听", + } +} + +pub(crate) fn config_change_title(locale: Locale) -> &'static str { + match locale { + Locale::En => "Config change detected", + Locale::ZhHans => "检测到配置变更", + } +} + +pub(crate) fn background_reconcile_title(locale: Locale) -> &'static str { + match locale { + Locale::En => "Background reconcile", + Locale::ZhHans => "后台收敛", + } +} + +pub(crate) fn watch_running_message( + locale: Locale, + codex_home: &Path, + poll_interval: Duration, +) -> String { + match locale { + Locale::En => format!( + "Watching {} with a {} ms poll interval. Press Ctrl-C to stop.", + codex_home.display(), + poll_interval.as_millis() + ), + Locale::ZhHans => format!( + "正在监听 {},轮询间隔 {} ms。按 Ctrl-C 停止。", + codex_home.display(), + poll_interval.as_millis() + ), + } +} + +pub(crate) fn watch_initial_reconcile_error_message(locale: Locale, err: &anyhow::Error) -> String { + match locale { + Locale::En => format!( + "Initial reconcile hit an error. Watch will keep running and retry on config changes or the next poll: {err:#}" + ), + Locale::ZhHans => { + format!("首轮收敛遇到错误。watch 会继续运行,并在配置变更或下一次轮询时重试:{err:#}") + } + } +} + +pub(crate) fn watch_reconcile_skipped_message(locale: Locale, err: &anyhow::Error) -> String { + match locale { + Locale::En => format!("Reconcile is waiting for the next retry: {err:#}"), + Locale::ZhHans => format!("本轮收敛等待下一次重试:{err:#}"), + } +} + +pub(crate) fn watcher_error_message(locale: Locale, err: notify::Error) -> String { + match locale { + Locale::En => format!("Watcher error: {err}"), + Locale::ZhHans => format!("监听器错误:{err}"), + } +} + +pub(crate) fn watcher_disconnected_error(locale: Locale) -> &'static str { + match locale { + Locale::En => "watcher disconnected", + Locale::ZhHans => "监听器已断开", + } +} + +pub(crate) fn watch_already_running_error(locale: Locale) -> &'static str { + match locale { + Locale::En => "another watch is already running for this codex home", + Locale::ZhHans => "这个 codex home 已经有另一个 watch 在运行", + } +} + +pub(crate) fn watch_stopped_message(locale: Locale) -> &'static str { + match locale { + Locale::En => "Watch stopped.", + Locale::ZhHans => "监听已停止。", + } +} + +pub(crate) fn install_launchd_done(locale: Locale) -> &'static str { + match locale { + Locale::En => "Installed background service.", + Locale::ZhHans => "已安装后台服务。", + } +} + +pub(crate) fn uninstall_launchd_done(locale: Locale) -> &'static str { + match locale { + Locale::En => "Removed background service.", + Locale::ZhHans => "已移除后台服务。", + } +} + +pub(crate) fn launchd_label_message(locale: Locale, label: &str) -> String { + match locale { + Locale::En => format!("Service label: {label}"), + Locale::ZhHans => format!("服务标签:{label}"), + } +} + +pub(crate) fn launchd_plist_message(locale: Locale, path: &Path) -> String { + match locale { + Locale::En => format!("Config path: {}", path.display()), + Locale::ZhHans => format!("配置路径:{}", path.display()), + } +} + +pub(crate) fn launchd_codex_home_message(locale: Locale, path: &Path) -> String { + match locale { + Locale::En => format!("Codex home: {}", path.display()), + Locale::ZhHans => format!("Codex home:{}", path.display()), + } +} + +pub(crate) fn launchd_polling_message(locale: Locale, poll_interval: Duration) -> String { + match locale { + Locale::En => format!("Polling every {} ms.", poll_interval.as_millis()), + Locale::ZhHans => format!("轮询间隔:{} ms。", poll_interval.as_millis()), + } +} + +pub(crate) fn next_steps_heading(locale: Locale) -> &'static str { + match locale { + Locale::En => "Next steps", + Locale::ZhHans => "下一步", + } +} + +pub(crate) fn print_install_service_summary( + locale: Locale, + codex_home: &Path, + poll_interval: Duration, + summary: &ServiceInstallSummary, +) { + println!("{}", install_launchd_done(locale)); + println!( + "{}: {}", + status_service_manager_label(locale), + service::manager_name(summary.manager) + ); + println!("{}", launchd_label_message(locale, service::SERVICE_LABEL)); + println!("{}", launchd_plist_message(locale, &summary.config_path)); + println!("{}", launchd_codex_home_message(locale, codex_home)); + println!("{}", launchd_polling_message(locale, poll_interval)); + println!("{}", service_log_message(locale, &summary.log_path)); +} + +pub(crate) fn install_next_steps( + locale: Locale, + exe_path: &Path, + codex_home: &Path, + provider_override: Option<&str>, + profile_override: Option<&str>, + manager: ServiceManager, +) -> Result> { + let status_command = + cli_status_command(exe_path, codex_home, provider_override, profile_override); + let log_path = service::log_path()?; + let mut steps = vec![run_status_next_step(locale, &status_command)]; + if let Some(command) = service::current_service_inspect_command()? { + steps.push(inspect_service_next_step(locale, manager, &command)); + } + steps.push(tail_log_next_step( + locale, + &tail_log_command(manager, &log_path), + )); + Ok(steps) +} + +pub(crate) fn cli_status_command( + exe_path: impl AsRef, + codex_home: &Path, + provider_override: Option<&str>, + profile_override: Option<&str>, +) -> String { + let mut parts = vec![ + shell_quote(exe_path.as_ref().display().to_string()), + "--codex-home".to_string(), + shell_quote(codex_home.display().to_string()), + ]; + if let Some(provider) = provider_override { + parts.push("--provider".to_string()); + parts.push(shell_quote(provider.to_string())); + } + if let Some(profile) = profile_override { + parts.push("--profile".to_string()); + parts.push(shell_quote(profile.to_string())); + } + parts.push("status".to_string()); + parts.join(" ") +} + +pub(crate) fn shell_quote(input: String) -> String { + if input + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '/' | '.' | '_' | '-')) + { + return input; + } + format!("'{}'", input.replace('\'', r"'\''")) +} + +pub(crate) fn run_status_next_step(locale: Locale, command: &str) -> String { + match locale { + Locale::En => format!("Run this to verify status: {command}"), + Locale::ZhHans => format!("运行这条命令查看状态:{command}"), + } +} + +pub(crate) fn inspect_service_next_step( + locale: Locale, + manager: ServiceManager, + command: &str, +) -> String { + let manager_name = service::manager_name(manager); + match locale { + Locale::En => format!("Run this to inspect the {manager_name} service: {command}"), + Locale::ZhHans => format!("运行这条命令查看 {manager_name} 服务:{command}"), + } +} + +pub(crate) fn tail_log_command(manager: ServiceManager, log_path: &Path) -> String { + match manager { + ServiceManager::WindowsStartup => format!( + "powershell -NoProfile -Command \"Get-Content -Path '{}' -Wait\"", + log_path.display() + ), + _ => format!("tail -f {}", log_path.display()), + } +} + +pub(crate) fn tail_log_next_step(locale: Locale, command: &str) -> String { + match locale { + Locale::En => format!("Run this to watch the live log: {command}"), + Locale::ZhHans => format!("运行这条命令查看实时日志:{command}"), + } +} + +pub(crate) fn no_launchd_plist_message(locale: Locale, path: &Path) -> String { + match locale { + Locale::En => format!( + "No background service config is installed at {}.", + path.display() + ), + Locale::ZhHans => format!("{} 这里还没有安装后台服务配置。", path.display()), + } +} + +pub(crate) fn status_title(locale: Locale) -> &'static str { + match locale { + Locale::En => "Codex Threadripper", + Locale::ZhHans => "Codex Threadripper", + } +} + +pub(crate) fn status_codex_home_label(locale: Locale) -> &'static str { + match locale { + Locale::En => "Codex home", + Locale::ZhHans => "Codex home", + } +} + +pub(crate) fn status_config_file_label(locale: Locale) -> &'static str { + match locale { + Locale::En => "Config file", + Locale::ZhHans => "配置文件", + } +} + +pub(crate) fn status_sqlite_file_label(locale: Locale) -> &'static str { + match locale { + Locale::En => "SQLite file", + Locale::ZhHans => "SQLite 文件", + } +} + +pub(crate) fn status_target_provider_label(locale: Locale) -> &'static str { + match locale { + Locale::En => "Target provider", + Locale::ZhHans => "目标 provider", + } +} + +pub(crate) fn status_total_threads_label(locale: Locale) -> &'static str { + match locale { + Locale::En => "Total threads", + Locale::ZhHans => "线程总数", + } +} + +pub(crate) fn status_rows_needing_reconcile_label(locale: Locale) -> &'static str { + match locale { + Locale::En => "Rows needing reconcile", + Locale::ZhHans => "待收敛行数", + } +} + +pub(crate) fn status_distribution_heading(locale: Locale) -> &'static str { + match locale { + Locale::En => "Provider distribution:", + Locale::ZhHans => "Provider 分布:", + } +} + +pub(crate) fn status_background_service_heading(locale: Locale) -> &'static str { + match locale { + Locale::En => "Background service:", + Locale::ZhHans => "后台服务:", + } +} + +pub(crate) fn status_service_manager_label(locale: Locale) -> &'static str { + match locale { + Locale::En => "Manager", + Locale::ZhHans => "管理器", + } +} + +pub(crate) fn status_plist_path_label(locale: Locale) -> &'static str { + match locale { + Locale::En => "Config path", + Locale::ZhHans => "配置路径", + } +} + +pub(crate) fn status_installed_label(locale: Locale) -> &'static str { + match locale { + Locale::En => "Installed", + Locale::ZhHans => "已安装", + } +} + +pub(crate) fn status_loaded_label(locale: Locale) -> &'static str { + match locale { + Locale::En => "Running", + Locale::ZhHans => "运行中", + } +} + +pub(crate) fn sync_rows_updated_label(locale: Locale) -> &'static str { + match locale { + Locale::En => "Rows updated", + Locale::ZhHans => "已更新行数", + } +} + +pub(crate) fn sync_rollouts_checked_label(locale: Locale) -> &'static str { + match locale { + Locale::En => "Rollouts checked", + Locale::ZhHans => "已检查 rollout", + } +} + +pub(crate) fn sync_rollouts_updated_label(locale: Locale) -> &'static str { + match locale { + Locale::En => "Rollouts updated", + Locale::ZhHans => "已更新 rollout", + } +} + +pub(crate) fn sync_rollouts_prepared_label(locale: Locale) -> &'static str { + match locale { + Locale::En => "Rollouts prepared", + Locale::ZhHans => "已准备 rollout", + } +} + +pub(crate) fn sync_rollouts_skipped_label(locale: Locale) -> &'static str { + match locale { + Locale::En => "Rollouts skipped", + Locale::ZhHans => "已跳过 rollout", + } +} + +pub(crate) fn sync_elapsed_label(locale: Locale) -> &'static str { + match locale { + Locale::En => "Elapsed", + Locale::ZhHans => "耗时", + } +} + +pub(crate) fn sync_backup_label(locale: Locale) -> &'static str { + match locale { + Locale::En => "Backup", + Locale::ZhHans => "备份文件", + } +} + +pub(crate) fn sync_rollout_journal_label(locale: Locale) -> &'static str { + match locale { + Locale::En => "Rollout first-line journal", + Locale::ZhHans => "rollout 首行记录", + } +} + +pub(crate) fn service_log_message(locale: Locale, path: &Path) -> String { + match locale { + Locale::En => format!("Log path: {}", path.display()), + Locale::ZhHans => format!("日志路径:{}", path.display()), + } +} diff --git a/src/rollout.rs b/src/rollout.rs new file mode 100644 index 0000000..170e195 --- /dev/null +++ b/src/rollout.rs @@ -0,0 +1,671 @@ +use anyhow::Context; +use anyhow::Result; +use filetime::FileTime; +use filetime::set_file_times; +use rusqlite::Connection; +use serde_json::Value; +use serde_json::json; +use std::fs; +use std::fs::File; +use std::fs::OpenOptions; +use std::io::BufRead; +use std::io::BufReader; +use std::io::BufWriter; +use std::io::IsTerminal; +use std::io::Seek; +use std::io::SeekFrom; +use std::io::Write; +use std::path::Path; +use std::path::PathBuf; +use std::time::Duration; +use std::time::Instant; + +use crate::codex_config::DEFAULT_PROVIDER; +use crate::codex_config::resolve_sqlite_path; +use crate::fs_sync::sync_dir; +use crate::locale::Locale; +use crate::output::RolloutProgressSnapshot; +use crate::output::rollout_progress_message; +use crate::state_db::ensure_sqlite_exists; +use crate::state_db::unix_timestamp_millis; + +const ROLLOUT_PROGRESS_INTERVAL: Duration = Duration::from_millis(500); + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum RolloutScope { + None, + MismatchedRows, + AllRows, +} + +#[derive(Debug)] +struct RolloutTarget { + thread_id: String, + path: PathBuf, +} + +#[derive(Debug, Default)] +pub(crate) struct RolloutReconcileSummary { + pub(crate) checked_files: u64, + pub(crate) changed_files: u64, + pub(crate) prepared_files: u64, + pub(crate) skipped_files: u64, + pub(crate) journal_path: Option, +} + +#[derive(Debug)] +pub(crate) struct BucketPrepareSummary { + pub(crate) checked_rollouts: u64, + pub(crate) prepared_rollouts: u64, + pub(crate) skipped_rollouts: u64, + pub(crate) elapsed: Duration, + pub(crate) journal_path: Option, +} + +#[derive(Clone, Copy, Debug)] +pub(crate) struct RolloutProgressConfig { + pub(crate) locale: Locale, +} + +struct RolloutProgress { + config: RolloutProgressConfig, + total_files: u64, + visited_files: u64, + started_at: Instant, + last_print_at: Instant, + is_terminal: bool, + printed: bool, +} + +#[derive(Debug)] +struct FirstLine { + content: Vec, + newline: Vec, +} + +impl RolloutProgress { + fn new(config: RolloutProgressConfig, total_files: u64) -> Self { + let now = Instant::now(); + Self { + config, + total_files, + visited_files: 0, + started_at: now, + last_print_at: now, + is_terminal: std::io::stderr().is_terminal(), + printed: false, + } + } + + fn tick(&mut self, summary: &RolloutReconcileSummary) { + if self.total_files == 0 { + return; + } + self.visited_files += 1; + let now = Instant::now(); + let should_print = self.visited_files == 1 + || self.visited_files == self.total_files + || now.duration_since(self.last_print_at) >= ROLLOUT_PROGRESS_INTERVAL; + if should_print { + self.print(summary, false); + self.last_print_at = now; + } + } + + fn finish(&mut self, summary: &RolloutReconcileSummary) { + if self.total_files == 0 { + return; + } + self.print(summary, true); + } + + fn print(&mut self, summary: &RolloutReconcileSummary, final_line: bool) { + let snapshot = RolloutProgressSnapshot { + visited_files: self.visited_files, + total_files: self.total_files, + checked_files: summary.checked_files, + changed_files: summary.changed_files, + prepared_files: summary.prepared_files, + skipped_files: summary.skipped_files, + elapsed: self.started_at.elapsed(), + }; + let message = rollout_progress_message(self.config.locale, &snapshot); + let mut stderr = std::io::stderr(); + if self.is_terminal { + let _ = write!(stderr, "\r{message}\x1b[K"); + if final_line { + let _ = writeln!(stderr); + } + } else if final_line || !self.printed { + let _ = writeln!(stderr, "{message}"); + } + let _ = stderr.flush(); + self.printed = true; + } +} + +#[derive(Debug, Default)] +struct RolloutPatchOutcome { + changed: bool, + prepared: bool, + skipped: bool, +} + +struct RolloutChangeJournal { + path: PathBuf, + writer: Option>, +} + +#[derive(Clone, Copy)] +enum RolloutChangeMode { + InPlace, + RewriteWithPadding, +} + +pub(crate) fn reconcile_rollout_metadata_from_sqlite_with_progress( + sqlite_path: &Path, + _codex_home: &Path, + provider: &str, + scope: RolloutScope, + journal_path: Option<&Path>, + padding_bytes: usize, + progress: Option, +) -> Result { + if scope == RolloutScope::None { + return Ok(RolloutReconcileSummary::default()); + } + let targets = rollout_targets_for_scope(sqlite_path, provider, scope)?; + reconcile_rollout_metadata_files( + targets.as_slice(), + provider, + journal_path, + padding_bytes, + progress, + ) +} + +fn rollout_targets_for_scope( + sqlite_path: &Path, + provider: &str, + scope: RolloutScope, +) -> Result> { + ensure_sqlite_exists(sqlite_path)?; + let connection = Connection::open(sqlite_path) + .with_context(|| format!("failed to open {}", sqlite_path.display()))?; + let sql = match scope { + RolloutScope::None => return Ok(Vec::new()), + RolloutScope::MismatchedRows => { + "SELECT id, rollout_path FROM threads WHERE model_provider <> ?1 AND rollout_path <> '' ORDER BY updated_at DESC" + } + RolloutScope::AllRows => { + "SELECT id, rollout_path FROM threads WHERE rollout_path <> '' ORDER BY updated_at DESC" + } + }; + let mut statement = connection.prepare(sql)?; + if scope == RolloutScope::MismatchedRows { + let rows = statement.query_map([provider], |row| { + Ok(RolloutTarget { + thread_id: row.get(0)?, + path: PathBuf::from(row.get::<_, String>(1)?), + }) + })?; + rows.collect::>>() + .map_err(Into::into) + } else { + let rows = statement.query_map([], |row| { + Ok(RolloutTarget { + thread_id: row.get(0)?, + path: PathBuf::from(row.get::<_, String>(1)?), + }) + })?; + rows.collect::>>() + .map_err(Into::into) + } +} + +fn reconcile_rollout_metadata_files( + targets: &[RolloutTarget], + provider: &str, + journal_path: Option<&Path>, + padding_bytes: usize, + progress: Option, +) -> Result { + let mut summary = RolloutReconcileSummary::default(); + let mut journal = journal_path.map(|path| RolloutChangeJournal::new(path.to_path_buf())); + let mut progress = progress.map(|config| RolloutProgress::new(config, targets.len() as u64)); + for target in targets { + if !target.path.exists() { + if let Some(progress) = progress.as_mut() { + progress.tick(&summary); + } + continue; + } + summary.checked_files += 1; + let outcome = + rewrite_rollout_provider_first_line(target, provider, journal.as_mut(), padding_bytes)?; + if outcome.changed { + summary.changed_files += 1; + } + if outcome.prepared { + summary.prepared_files += 1; + } + if outcome.skipped { + summary.skipped_files += 1; + } + if let Some(progress) = progress.as_mut() { + progress.tick(&summary); + } + } + if let Some(progress) = progress.as_mut() { + progress.finish(&summary); + } + if let Some(journal) = journal { + summary.journal_path = journal.finish()?; + } + Ok(summary) +} + +fn rewrite_rollout_provider_first_line( + target: &RolloutTarget, + provider: &str, + journal: Option<&mut RolloutChangeJournal>, + padding_bytes: usize, +) -> Result { + let first_line = read_first_line(&target.path)?; + let Some(mut value) = + parse_matching_session_meta(first_line.content.as_slice(), target.thread_id.as_str())? + else { + return Ok(RolloutPatchOutcome { + skipped: true, + ..RolloutPatchOutcome::default() + }); + }; + let old_provider = session_meta_provider(&value).unwrap_or("").to_string(); + + if !set_session_meta_provider(&mut value, provider) { + return Ok(RolloutPatchOutcome::default()); + } + + let rendered = serde_json::to_vec(&value) + .with_context(|| format!("failed to render {}", target.path.display()))?; + if rendered.len() <= first_line.content.len() { + let mut replacement = rendered; + replacement.resize(first_line.content.len(), b' '); + record_rollout_change( + journal, + target, + old_provider.as_str(), + provider, + first_line.content.len(), + replacement.len(), + RolloutChangeMode::InPlace, + )?; + patch_first_line_in_place( + &target.path, + replacement.as_slice(), + first_line.newline.as_slice(), + )?; + return Ok(RolloutPatchOutcome { + changed: true, + ..RolloutPatchOutcome::default() + }); + } + + let mut replacement = rendered; + replacement.resize(replacement.len() + padding_bytes, b' '); + record_rollout_change( + journal, + target, + old_provider.as_str(), + provider, + first_line.content.len(), + replacement.len(), + RolloutChangeMode::RewriteWithPadding, + )?; + rewrite_first_line_atomically( + &target.path, + replacement.as_slice(), + first_line.newline.as_slice(), + )?; + Ok(RolloutPatchOutcome { + changed: true, + prepared: true, + ..RolloutPatchOutcome::default() + }) +} + +pub(crate) fn prepare_bucket_padding( + codex_home: &Path, + profile_override: Option<&str>, + padding_bytes: usize, +) -> Result { + let sqlite_path = resolve_sqlite_path(codex_home, profile_override)?; + let started = Instant::now(); + let targets = rollout_targets_for_scope(&sqlite_path, DEFAULT_PROVIDER, RolloutScope::AllRows)?; + let journal_path = codex_home + .join("backups") + .join(format!("bucket-prepare.{}.jsonl", unix_timestamp_millis()?)); + let mut journal = RolloutChangeJournal::new(journal_path); + let mut checked_rollouts = 0; + let mut prepared_rollouts = 0; + let mut skipped_rollouts = 0; + + for target in &targets { + if !target.path.exists() { + continue; + } + checked_rollouts += 1; + let outcome = prepare_rollout_first_line_padding(target, &mut journal, padding_bytes)?; + if outcome.prepared { + prepared_rollouts += 1; + } + if outcome.skipped { + skipped_rollouts += 1; + } + } + let journal_path = journal.finish()?; + + Ok(BucketPrepareSummary { + checked_rollouts, + prepared_rollouts, + skipped_rollouts, + elapsed: started.elapsed(), + journal_path, + }) +} + +fn prepare_rollout_first_line_padding( + target: &RolloutTarget, + journal: &mut RolloutChangeJournal, + padding_bytes: usize, +) -> Result { + let first_line = read_first_line(&target.path)?; + let Some(value) = + parse_matching_session_meta(first_line.content.as_slice(), target.thread_id.as_str())? + else { + return Ok(RolloutPatchOutcome { + skipped: true, + ..RolloutPatchOutcome::default() + }); + }; + let old_provider = session_meta_provider(&value).unwrap_or("").to_string(); + + let rendered = serde_json::to_vec(&value) + .with_context(|| format!("failed to render {}", target.path.display()))?; + let desired_len = rendered.len() + padding_bytes; + if first_line.content.len() >= desired_len { + return Ok(RolloutPatchOutcome::default()); + } + + let mut replacement = rendered; + replacement.resize(desired_len, b' '); + record_rollout_change( + Some(journal), + target, + old_provider.as_str(), + old_provider.as_str(), + first_line.content.len(), + replacement.len(), + RolloutChangeMode::RewriteWithPadding, + )?; + rewrite_first_line_atomically( + &target.path, + replacement.as_slice(), + first_line.newline.as_slice(), + )?; + Ok(RolloutPatchOutcome { + changed: true, + prepared: true, + ..RolloutPatchOutcome::default() + }) +} + +fn read_first_line(path: &Path) -> Result { + let file = File::open(path).with_context(|| format!("failed to open {}", path.display()))?; + let mut reader = BufReader::new(file); + let mut buffer = Vec::new(); + reader + .read_until(b'\n', &mut buffer) + .with_context(|| format!("failed to read {}", path.display()))?; + if buffer.is_empty() { + anyhow::bail!("empty rollout file: {}", path.display()); + } + + let mut newline = Vec::new(); + if buffer.ends_with(b"\n") { + buffer.pop(); + if buffer.ends_with(b"\r") { + buffer.pop(); + newline.extend_from_slice(b"\r\n"); + } else { + newline.push(b'\n'); + } + } + + Ok(FirstLine { + content: buffer, + newline, + }) +} + +fn parse_matching_session_meta(line: &[u8], thread_id: &str) -> Result> { + let value = serde_json::from_slice::(line)?; + if session_meta_belongs_to_thread(&value, thread_id) { + Ok(Some(value)) + } else { + Ok(None) + } +} + +fn patch_first_line_in_place(path: &Path, replacement: &[u8], newline: &[u8]) -> Result<()> { + let original_times = capture_file_times(path)?; + { + let mut file = OpenOptions::new() + .write(true) + .open(path) + .with_context(|| format!("failed to open {}", path.display()))?; + file.seek(SeekFrom::Start(0)) + .with_context(|| format!("failed to seek {}", path.display()))?; + file.write_all(replacement) + .with_context(|| format!("failed to write {}", path.display()))?; + file.write_all(newline) + .with_context(|| format!("failed to write {}", path.display()))?; + } + restore_file_times(path, original_times)?; + Ok(()) +} + +fn rewrite_first_line_atomically(path: &Path, replacement: &[u8], newline: &[u8]) -> Result<()> { + let parent = path.parent().unwrap_or_else(|| Path::new(".")); + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("rollout.jsonl"); + let temp_path = parent.join(format!( + ".{file_name}.{}.threadripper.tmp", + std::process::id() + )); + let metadata = + fs::metadata(path).with_context(|| format!("failed to stat {}", path.display()))?; + let original_times = file_times_from_metadata(&metadata); + + { + let input = + File::open(path).with_context(|| format!("failed to open {}", path.display()))?; + let mut reader = BufReader::new(input); + let mut ignored_first_line = Vec::new(); + reader + .read_until(b'\n', &mut ignored_first_line) + .with_context(|| format!("failed to read {}", path.display()))?; + + let output = File::create(&temp_path) + .with_context(|| format!("failed to create {}", temp_path.display()))?; + fs::set_permissions(&temp_path, metadata.permissions()) + .with_context(|| format!("failed to set permissions on {}", temp_path.display()))?; + let mut writer = BufWriter::new(output); + writer + .write_all(replacement) + .with_context(|| format!("failed to write {}", temp_path.display()))?; + writer + .write_all(newline) + .with_context(|| format!("failed to write {}", temp_path.display()))?; + std::io::copy(&mut reader, &mut writer) + .with_context(|| format!("failed to copy {}", path.display()))?; + writer + .flush() + .with_context(|| format!("failed to flush {}", temp_path.display()))?; + writer + .get_ref() + .sync_all() + .with_context(|| format!("fsync: {}", temp_path.display()))?; + } + + fs::rename(&temp_path, path).with_context(|| { + format!( + "failed to replace {} with {}", + path.display(), + temp_path.display() + ) + })?; + restore_file_times(path, original_times)?; + sync_dir(parent)?; + Ok(()) +} + +fn capture_file_times(path: &Path) -> Result<(FileTime, FileTime)> { + let metadata = + fs::metadata(path).with_context(|| format!("failed to stat {}", path.display()))?; + Ok(file_times_from_metadata(&metadata)) +} + +fn file_times_from_metadata(metadata: &fs::Metadata) -> (FileTime, FileTime) { + ( + FileTime::from_last_access_time(metadata), + FileTime::from_last_modification_time(metadata), + ) +} + +fn restore_file_times(path: &Path, times: (FileTime, FileTime)) -> Result<()> { + set_file_times(path, times.0, times.1) + .with_context(|| format!("failed to restore file times for {}", path.display())) +} + +fn record_rollout_change( + journal: Option<&mut RolloutChangeJournal>, + target: &RolloutTarget, + old_provider: &str, + new_provider: &str, + old_len: usize, + new_len: usize, + mode: RolloutChangeMode, +) -> Result<()> { + if let Some(journal) = journal { + journal.record(target, old_provider, new_provider, old_len, new_len, mode)?; + } + Ok(()) +} + +impl RolloutChangeJournal { + fn new(path: PathBuf) -> Self { + Self { path, writer: None } + } + + fn record( + &mut self, + target: &RolloutTarget, + old_provider: &str, + new_provider: &str, + old_len: usize, + new_len: usize, + mode: RolloutChangeMode, + ) -> Result<()> { + if self.writer.is_none() { + if let Some(parent) = self.path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("failed to create {}", parent.display()))?; + } + let file = File::create(&self.path) + .with_context(|| format!("failed to create {}", self.path.display()))?; + self.writer = Some(BufWriter::new(file)); + } + + let writer = self.writer.as_mut().expect("journal writer initialized"); + serde_json::to_writer( + &mut *writer, + &json!({ + "path": target.path.display().to_string(), + "thread_id": target.thread_id.as_str(), + "mode": mode.as_str(), + "old_provider": old_provider, + "new_provider": new_provider, + "old_first_line_len": old_len, + "new_first_line_len": new_len, + }), + ) + .with_context(|| format!("failed to write {}", self.path.display()))?; + writer + .write_all(b"\n") + .with_context(|| format!("failed to write {}", self.path.display()))?; + Ok(()) + } + + fn finish(mut self) -> Result> { + let Some(mut writer) = self.writer.take() else { + return Ok(None); + }; + writer + .flush() + .with_context(|| format!("failed to flush {}", self.path.display()))?; + writer + .get_ref() + .sync_all() + .with_context(|| format!("fsync: {}", self.path.display()))?; + if let Some(parent) = self.path.parent() { + sync_dir(parent)?; + } + Ok(Some(self.path)) + } +} + +impl RolloutChangeMode { + fn as_str(self) -> &'static str { + match self { + Self::InPlace => "in_place", + Self::RewriteWithPadding => "rewrite_with_padding", + } + } +} + +fn session_meta_belongs_to_thread(value: &Value, thread_id: &str) -> bool { + value.get("type").and_then(Value::as_str) == Some("session_meta") + && value + .get("payload") + .and_then(|payload| payload.get("id")) + .and_then(Value::as_str) + == Some(thread_id) +} + +fn session_meta_provider(value: &Value) -> Option<&str> { + value + .get("payload") + .and_then(|payload| payload.get("model_provider")) + .and_then(Value::as_str) +} + +fn set_session_meta_provider(value: &mut Value, provider: &str) -> bool { + let Some(payload) = value.get_mut("payload").and_then(Value::as_object_mut) else { + return false; + }; + if payload + .get("model_provider") + .and_then(Value::as_str) + .is_some_and(|current| current == provider) + { + return false; + } + payload.insert( + "model_provider".to_string(), + Value::String(provider.to_string()), + ); + true +} diff --git a/src/service.rs b/src/service.rs index 57e925a..e19c86a 100644 --- a/src/service.rs +++ b/src/service.rs @@ -1,23 +1,18 @@ use anyhow::Context; use anyhow::Result; use std::fmt::Write as _; -use std::fs::OpenOptions; use std::path::Path; use std::path::PathBuf; use std::process::Command as ProcessCommand; -use std::process::Stdio; use std::time::Duration; -#[cfg(target_os = "windows")] -use std::os::windows::process::CommandExt; +mod process; + +use process::detached_watch_running; +use process::start_detached_watch; +use process::stop_detached_watch_if_present; pub const SERVICE_LABEL: &str = "dev.wangnov.codex-threadripper"; -#[cfg(target_os = "windows")] -const WINDOWS_CREATE_NO_WINDOW: u32 = 0x0800_0000; -#[cfg(target_os = "windows")] -const WINDOWS_DETACHED_PROCESS: u32 = 0x0000_0008; -#[cfg(target_os = "windows")] -const WINDOWS_CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200; #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum ServiceManager { @@ -619,239 +614,6 @@ enum ShellFlavor { WindowsProcess, } -fn start_detached_watch( - exe_path: &Path, - codex_home: &Path, - provider_override: Option<&str>, - profile_override: Option<&str>, - poll_interval: Duration, -) -> Result<()> { - let stdout_path = log_path_for(codex_home)?; - let stderr_path = stderr_log_path_for(codex_home)?; - if let Some(parent) = stdout_path.parent() { - std::fs::create_dir_all(parent) - .with_context(|| format!("failed to create {}", parent.display()))?; - } - let stdout = OpenOptions::new() - .create(true) - .append(true) - .open(&stdout_path) - .with_context(|| format!("failed to open {}", stdout_path.display()))?; - let stderr = OpenOptions::new() - .create(true) - .append(true) - .open(&stderr_path) - .with_context(|| format!("failed to open {}", stderr_path.display()))?; - - let mut command = ProcessCommand::new(exe_path); - command.arg("--codex-home").arg(codex_home); - - if let Some(provider) = provider_override { - command.arg("--provider").arg(provider); - } - if let Some(profile) = profile_override { - command.arg("--profile").arg(profile); - } - - command - .arg("watch") - .arg("--poll-interval-ms") - .arg(poll_interval.as_millis().to_string()) - .stdin(Stdio::null()) - .stdout(Stdio::from(stdout)) - .stderr(Stdio::from(stderr)); - - #[cfg(target_os = "windows")] - { - command.creation_flags( - WINDOWS_CREATE_NO_WINDOW | WINDOWS_DETACHED_PROCESS | WINDOWS_CREATE_NEW_PROCESS_GROUP, - ); - } - - let child = command - .spawn() - .context("failed to start detached watch process")?; - write_pid_file(child.id(), codex_home)?; - Ok(()) -} - -fn stop_detached_watch_if_present(codex_home: &Path) -> Result<()> { - let Some(pid_file) = read_pid_file(codex_home)? else { - return Ok(()); - }; - match process_is_running(pid_file.pid, pid_file.exe_path.as_deref())? { - ProcessStatus::Running => { - stop_process(pid_file.pid)?; - remove_pid_file_if_exists(codex_home)?; - } - ProcessStatus::NotRunning => { - remove_pid_file_if_exists(codex_home)?; - } - ProcessStatus::RunningMismatched => {} - } - Ok(()) -} - -fn detached_watch_running(codex_home: &Path) -> Result { - let Some(pid_file) = read_pid_file(codex_home)? else { - return Ok(false); - }; - match process_is_running(pid_file.pid, pid_file.exe_path.as_deref())? { - ProcessStatus::Running => Ok(true), - ProcessStatus::NotRunning => { - remove_pid_file_if_exists(codex_home)?; - Ok(false) - } - ProcessStatus::RunningMismatched => Ok(false), - } -} - -fn pid_file_path(codex_home: &Path) -> Result { - Ok(runtime_dir(codex_home)?.join("watch.pid")) -} - -fn write_pid_file(pid: u32, codex_home: &Path) -> Result<()> { - let pid_path = pid_file_path(codex_home)?; - if let Some(parent) = pid_path.parent() { - std::fs::create_dir_all(parent) - .with_context(|| format!("failed to create {}", parent.display()))?; - } - let exe_path = std::env::current_exe() - .context("failed to read current executable for pid file")? - .to_string_lossy() - .into_owned(); - std::fs::write(&pid_path, format!("{pid}\n{exe_path}\n")) - .with_context(|| format!("failed to write {}", pid_path.display()))?; - Ok(()) -} - -fn read_pid_file(codex_home: &Path) -> Result> { - let pid_path = pid_file_path(codex_home)?; - if !pid_path.exists() { - return Ok(None); - } - let raw = std::fs::read_to_string(&pid_path) - .with_context(|| format!("failed to read {}", pid_path.display()))?; - let mut lines = raw.lines(); - let pid = lines - .next() - .context("pid file is empty")? - .trim() - .parse::() - .context("failed to parse pid file")?; - let exe_path = lines - .next() - .map(str::trim) - .filter(|line| !line.is_empty()) - .map(PathBuf::from); - Ok(Some(PidFile { pid, exe_path })) -} - -fn remove_pid_file_if_exists(codex_home: &Path) -> Result<()> { - let pid_path = pid_file_path(codex_home)?; - if pid_path.exists() { - std::fs::remove_file(&pid_path) - .with_context(|| format!("failed to remove {}", pid_path.display()))?; - } - Ok(()) -} - -fn process_is_running(pid: u32, expected_exe: Option<&Path>) -> Result { - #[cfg(target_os = "windows")] - { - let filter = format!("PID eq {pid}"); - let output = ProcessCommand::new("tasklist") - .args(["/FI", filter.as_str(), "/FO", "CSV", "/NH"]) - .output()?; - if !output.status.success() { - return Ok(ProcessStatus::NotRunning); - } - let stdout = String::from_utf8_lossy(&output.stdout); - let trimmed = stdout.trim(); - if trimmed.is_empty() || trimmed.starts_with("INFO:") { - return Ok(ProcessStatus::NotRunning); - } - if trimmed.to_ascii_lowercase().contains("codex-threadripper") { - return Ok(ProcessStatus::Running); - } - let _ = expected_exe; - Ok(ProcessStatus::RunningMismatched) - } - #[cfg(target_os = "macos")] - { - let status = ProcessCommand::new("kill") - .arg("-0") - .arg(pid.to_string()) - .status()?; - if !status.success() { - return Ok(ProcessStatus::NotRunning); - } - let output = ProcessCommand::new("ps") - .args(["-p", &pid.to_string(), "-o", "comm="]) - .output()?; - if !output.status.success() { - return Ok(ProcessStatus::NotRunning); - } - if String::from_utf8_lossy(&output.stdout) - .to_ascii_lowercase() - .contains("codex-threadripper") - { - return Ok(ProcessStatus::Running); - } - let _ = expected_exe; - Ok(ProcessStatus::RunningMismatched) - } - #[cfg(target_os = "linux")] - { - let status = ProcessCommand::new("kill") - .arg("-0") - .arg(pid.to_string()) - .status()?; - if !status.success() { - return Ok(ProcessStatus::NotRunning); - } - let Some(expected_exe) = expected_exe else { - return Ok(ProcessStatus::Running); - }; - let actual_exe = match std::fs::read_link(format!("/proc/{pid}/exe")) { - Ok(path) => path, - Err(_) => return Ok(ProcessStatus::NotRunning), - }; - if executable_paths_match(&actual_exe, expected_exe) { - return Ok(ProcessStatus::Running); - } - Ok(ProcessStatus::RunningMismatched) - } -} - -fn stop_process(pid: u32) -> Result<()> { - #[cfg(target_os = "windows")] - { - let output = ProcessCommand::new("taskkill") - .args(["/PID", &pid.to_string(), "/F", "/T"]) - .output()?; - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - let stdout = String::from_utf8_lossy(&output.stdout); - if !stderr.contains("not found") && !stdout.contains("not found") { - anyhow::bail!( - "failed to stop pid {pid}\nstdout: {}\nstderr: {}", - stdout.trim(), - stderr.trim() - ); - } - } - } - #[cfg(not(target_os = "windows"))] - { - let _ = ProcessCommand::new("kill") - .arg("-TERM") - .arg(pid.to_string()) - .status()?; - } - Ok(()) -} - fn try_run_systemd_user_unit() -> Result { let daemon_reload = run_command_capture("systemctl", ["--user", "daemon-reload"])?; if !daemon_reload.status.success() { @@ -1147,130 +909,5 @@ fn make_executable(_path: &Path) -> Result<()> { Ok(()) } -#[derive(Debug)] -struct PidFile { - pid: u32, - exe_path: Option, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum ProcessStatus { - Running, - RunningMismatched, - NotRunning, -} - #[cfg(test)] -mod tests { - use super::build_linux_runner_script; - use super::build_windows_startup_vbs; - use super::installed_windows_startup_config_path; - use anyhow::Result; - use std::fs; - use std::path::Path; - use std::time::Duration; - - #[test] - fn windows_status_config_path_uses_legacy_cmd_when_vbs_is_missing() -> Result<()> { - let dir = tempfile::tempdir()?; - let vbs_path = dir.path().join("dev.wangnov.codex-threadripper.vbs"); - let cmd_path = dir.path().join("dev.wangnov.codex-threadripper.cmd"); - fs::write(&cmd_path, "@echo off\r\n")?; - - assert_eq!( - installed_windows_startup_config_path(&vbs_path, &cmd_path), - cmd_path - ); - - Ok(()) - } - - #[test] - fn windows_startup_vbs_quotes_space_paths_without_cmd_wrapper() { - let script = build_windows_startup_vbs( - Path::new(r"C:\Program Files\codex threadripper\codex-threadripper.exe"), - Path::new(r"C:\Users\Admin User\.codex"), - None, - None, - Duration::from_millis(1500), - ); - let command = shell_run_command(&script); - - assert!(script.contains(r#"CreateObject("WScript.Shell")"#)); - assert!(script.contains("shell.Run")); - assert!(script.contains(", 0, False")); - assert!(!script.contains("cmd.exe /c")); - assert_eq!( - command, - concat!( - r#""C:\Program Files\codex threadripper\codex-threadripper.exe" "#, - r#"--codex-home "C:\Users\Admin User\.codex" "#, - "watch --poll-interval-ms 1500" - ) - ); - } - - #[test] - fn windows_startup_vbs_escapes_provider_quotes_and_spaces() { - let script = build_windows_startup_vbs( - Path::new(r"C:\Tools\codex-threadripper.exe"), - Path::new(r"C:\Codex"), - Some(r#"open ai "beta""#), - None, - Duration::from_millis(500), - ); - let command = shell_run_command(&script); - - assert!(!script.contains("cmd.exe /c")); - assert_eq!( - command, - concat!( - r"C:\Tools\codex-threadripper.exe --codex-home C:\Codex ", - r#"--provider "open ai \"beta\"" watch --poll-interval-ms 500"# - ) - ); - } - - #[test] - fn linux_runner_script_preserves_profile_override() { - let script = build_linux_runner_script( - Path::new("/opt/codex threadripper/bin/codex-threadripper"), - Path::new("/home/user/.codex"), - None, - Some("work"), - Duration::from_millis(500), - ); - - assert!(script.contains("'/opt/codex threadripper/bin/codex-threadripper'")); - assert!(script.contains("--profile work")); - assert!(script.contains("watch --poll-interval-ms 500")); - } - - #[test] - fn windows_startup_vbs_preserves_profile_override() { - let script = build_windows_startup_vbs( - Path::new(r"C:\Tools\codex-threadripper.exe"), - Path::new(r"C:\Codex"), - None, - Some("work"), - Duration::from_millis(500), - ); - let command = shell_run_command(&script); - - assert!(command.contains("--profile work")); - assert!(command.ends_with("watch --poll-interval-ms 500")); - } - - fn shell_run_command(script: &str) -> String { - let line = script - .lines() - .find(|line| line.starts_with("shell.Run ")) - .expect("script should contain shell.Run"); - let literal = line - .strip_prefix("shell.Run ") - .and_then(|value| value.strip_suffix(", 0, False")) - .expect("shell.Run should pass a quoted command literal"); - assert!(literal.starts_with('"') && literal.ends_with('"')); - literal[1..literal.len() - 1].replace("\"\"", "\"") - } -} +mod tests; diff --git a/src/service/process.rs b/src/service/process.rs new file mode 100644 index 0000000..d3d118d --- /dev/null +++ b/src/service/process.rs @@ -0,0 +1,270 @@ +use anyhow::Context; +use anyhow::Result; +use std::fs::OpenOptions; +use std::path::Path; +use std::path::PathBuf; +use std::process::Command as ProcessCommand; +use std::process::Stdio; +use std::time::Duration; + +#[cfg(target_os = "windows")] +use std::os::windows::process::CommandExt; + +#[cfg(target_os = "linux")] +use super::executable_paths_match; +use super::log_path_for; +use super::runtime_dir; +use super::stderr_log_path_for; + +#[cfg(target_os = "windows")] +const WINDOWS_CREATE_NO_WINDOW: u32 = 0x0800_0000; +#[cfg(target_os = "windows")] +const WINDOWS_DETACHED_PROCESS: u32 = 0x0000_0008; +#[cfg(target_os = "windows")] +const WINDOWS_CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200; + +pub(super) fn start_detached_watch( + exe_path: &Path, + codex_home: &Path, + provider_override: Option<&str>, + profile_override: Option<&str>, + poll_interval: Duration, +) -> Result<()> { + let stdout_path = log_path_for(codex_home)?; + let stderr_path = stderr_log_path_for(codex_home)?; + if let Some(parent) = stdout_path.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("failed to create {}", parent.display()))?; + } + let stdout = OpenOptions::new() + .create(true) + .append(true) + .open(&stdout_path) + .with_context(|| format!("failed to open {}", stdout_path.display()))?; + let stderr = OpenOptions::new() + .create(true) + .append(true) + .open(&stderr_path) + .with_context(|| format!("failed to open {}", stderr_path.display()))?; + + let mut command = ProcessCommand::new(exe_path); + command.arg("--codex-home").arg(codex_home); + + if let Some(provider) = provider_override { + command.arg("--provider").arg(provider); + } + if let Some(profile) = profile_override { + command.arg("--profile").arg(profile); + } + + command + .arg("watch") + .arg("--poll-interval-ms") + .arg(poll_interval.as_millis().to_string()) + .stdin(Stdio::null()) + .stdout(Stdio::from(stdout)) + .stderr(Stdio::from(stderr)); + + #[cfg(target_os = "windows")] + { + command.creation_flags( + WINDOWS_CREATE_NO_WINDOW | WINDOWS_DETACHED_PROCESS | WINDOWS_CREATE_NEW_PROCESS_GROUP, + ); + } + + let child = command + .spawn() + .context("failed to start detached watch process")?; + write_pid_file(child.id(), codex_home)?; + Ok(()) +} + +pub(super) fn stop_detached_watch_if_present(codex_home: &Path) -> Result<()> { + let Some(pid_file) = read_pid_file(codex_home)? else { + return Ok(()); + }; + match process_is_running(pid_file.pid, pid_file.exe_path.as_deref())? { + ProcessStatus::Running => { + stop_process(pid_file.pid)?; + remove_pid_file_if_exists(codex_home)?; + } + ProcessStatus::NotRunning => { + remove_pid_file_if_exists(codex_home)?; + } + ProcessStatus::RunningMismatched => {} + } + Ok(()) +} + +pub(super) fn detached_watch_running(codex_home: &Path) -> Result { + let Some(pid_file) = read_pid_file(codex_home)? else { + return Ok(false); + }; + match process_is_running(pid_file.pid, pid_file.exe_path.as_deref())? { + ProcessStatus::Running => Ok(true), + ProcessStatus::NotRunning => { + remove_pid_file_if_exists(codex_home)?; + Ok(false) + } + ProcessStatus::RunningMismatched => Ok(false), + } +} + +fn pid_file_path(codex_home: &Path) -> Result { + Ok(runtime_dir(codex_home)?.join("watch.pid")) +} + +fn write_pid_file(pid: u32, codex_home: &Path) -> Result<()> { + let pid_path = pid_file_path(codex_home)?; + if let Some(parent) = pid_path.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("failed to create {}", parent.display()))?; + } + let exe_path = std::env::current_exe() + .context("failed to read current executable for pid file")? + .to_string_lossy() + .into_owned(); + std::fs::write(&pid_path, format!("{pid}\n{exe_path}\n")) + .with_context(|| format!("failed to write {}", pid_path.display()))?; + Ok(()) +} + +fn read_pid_file(codex_home: &Path) -> Result> { + let pid_path = pid_file_path(codex_home)?; + if !pid_path.exists() { + return Ok(None); + } + let raw = std::fs::read_to_string(&pid_path) + .with_context(|| format!("failed to read {}", pid_path.display()))?; + let mut lines = raw.lines(); + let pid = lines + .next() + .context("pid file is empty")? + .trim() + .parse::() + .context("failed to parse pid file")?; + let exe_path = lines + .next() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(PathBuf::from); + Ok(Some(PidFile { pid, exe_path })) +} + +fn remove_pid_file_if_exists(codex_home: &Path) -> Result<()> { + let pid_path = pid_file_path(codex_home)?; + if pid_path.exists() { + std::fs::remove_file(&pid_path) + .with_context(|| format!("failed to remove {}", pid_path.display()))?; + } + Ok(()) +} + +fn process_is_running(pid: u32, expected_exe: Option<&Path>) -> Result { + #[cfg(target_os = "windows")] + { + let filter = format!("PID eq {pid}"); + let output = ProcessCommand::new("tasklist") + .args(["/FI", filter.as_str(), "/FO", "CSV", "/NH"]) + .output()?; + if !output.status.success() { + return Ok(ProcessStatus::NotRunning); + } + let stdout = String::from_utf8_lossy(&output.stdout); + let trimmed = stdout.trim(); + if trimmed.is_empty() || trimmed.starts_with("INFO:") { + return Ok(ProcessStatus::NotRunning); + } + if trimmed.to_ascii_lowercase().contains("codex-threadripper") { + return Ok(ProcessStatus::Running); + } + let _ = expected_exe; + Ok(ProcessStatus::RunningMismatched) + } + #[cfg(target_os = "macos")] + { + let status = ProcessCommand::new("kill") + .arg("-0") + .arg(pid.to_string()) + .status()?; + if !status.success() { + return Ok(ProcessStatus::NotRunning); + } + let output = ProcessCommand::new("ps") + .args(["-p", &pid.to_string(), "-o", "comm="]) + .output()?; + if !output.status.success() { + return Ok(ProcessStatus::NotRunning); + } + if String::from_utf8_lossy(&output.stdout) + .to_ascii_lowercase() + .contains("codex-threadripper") + { + return Ok(ProcessStatus::Running); + } + let _ = expected_exe; + Ok(ProcessStatus::RunningMismatched) + } + #[cfg(target_os = "linux")] + { + let status = ProcessCommand::new("kill") + .arg("-0") + .arg(pid.to_string()) + .status()?; + if !status.success() { + return Ok(ProcessStatus::NotRunning); + } + let Some(expected_exe) = expected_exe else { + return Ok(ProcessStatus::Running); + }; + let actual_exe = match std::fs::read_link(format!("/proc/{pid}/exe")) { + Ok(path) => path, + Err(_) => return Ok(ProcessStatus::NotRunning), + }; + if executable_paths_match(&actual_exe, expected_exe) { + return Ok(ProcessStatus::Running); + } + Ok(ProcessStatus::RunningMismatched) + } +} + +fn stop_process(pid: u32) -> Result<()> { + #[cfg(target_os = "windows")] + { + let output = ProcessCommand::new("taskkill") + .args(["/PID", &pid.to_string(), "/F", "/T"]) + .output()?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + if !stderr.contains("not found") && !stdout.contains("not found") { + anyhow::bail!( + "failed to stop pid {pid}\nstdout: {}\nstderr: {}", + stdout.trim(), + stderr.trim() + ); + } + } + } + #[cfg(not(target_os = "windows"))] + { + let _ = ProcessCommand::new("kill") + .arg("-TERM") + .arg(pid.to_string()) + .status()?; + } + Ok(()) +} + +#[derive(Debug)] +struct PidFile { + pid: u32, + exe_path: Option, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum ProcessStatus { + Running, + RunningMismatched, + NotRunning, +} diff --git a/src/service/tests.rs b/src/service/tests.rs new file mode 100644 index 0000000..8a68a98 --- /dev/null +++ b/src/service/tests.rs @@ -0,0 +1,111 @@ +use super::build_linux_runner_script; +use super::build_windows_startup_vbs; +use super::installed_windows_startup_config_path; +use anyhow::Result; +use std::fs; +use std::path::Path; +use std::time::Duration; + +#[test] +fn windows_status_config_path_uses_legacy_cmd_when_vbs_is_missing() -> Result<()> { + let dir = tempfile::tempdir()?; + let vbs_path = dir.path().join("dev.wangnov.codex-threadripper.vbs"); + let cmd_path = dir.path().join("dev.wangnov.codex-threadripper.cmd"); + fs::write(&cmd_path, "@echo off\r\n")?; + + assert_eq!( + installed_windows_startup_config_path(&vbs_path, &cmd_path), + cmd_path + ); + + Ok(()) +} + +#[test] +fn windows_startup_vbs_quotes_space_paths_without_cmd_wrapper() { + let script = build_windows_startup_vbs( + Path::new(r"C:\Program Files\codex threadripper\codex-threadripper.exe"), + Path::new(r"C:\Users\Admin User\.codex"), + None, + None, + Duration::from_millis(1500), + ); + let command = shell_run_command(&script); + + assert!(script.contains(r#"CreateObject("WScript.Shell")"#)); + assert!(script.contains("shell.Run")); + assert!(script.contains(", 0, False")); + assert!(!script.contains("cmd.exe /c")); + assert_eq!( + command, + concat!( + r#""C:\Program Files\codex threadripper\codex-threadripper.exe" "#, + r#"--codex-home "C:\Users\Admin User\.codex" "#, + "watch --poll-interval-ms 1500" + ) + ); +} + +#[test] +fn windows_startup_vbs_escapes_provider_quotes_and_spaces() { + let script = build_windows_startup_vbs( + Path::new(r"C:\Tools\codex-threadripper.exe"), + Path::new(r"C:\Codex"), + Some(r#"open ai "beta""#), + None, + Duration::from_millis(500), + ); + let command = shell_run_command(&script); + + assert!(!script.contains("cmd.exe /c")); + assert_eq!( + command, + concat!( + r"C:\Tools\codex-threadripper.exe --codex-home C:\Codex ", + r#"--provider "open ai \"beta\"" watch --poll-interval-ms 500"# + ) + ); +} + +#[test] +fn linux_runner_script_preserves_profile_override() { + let script = build_linux_runner_script( + Path::new("/opt/codex threadripper/bin/codex-threadripper"), + Path::new("/home/user/.codex"), + None, + Some("work"), + Duration::from_millis(500), + ); + + assert!(script.contains("'/opt/codex threadripper/bin/codex-threadripper'")); + assert!(script.contains("--profile work")); + assert!(script.contains("watch --poll-interval-ms 500")); +} + +#[test] +fn windows_startup_vbs_preserves_profile_override() { + let script = build_windows_startup_vbs( + Path::new(r"C:\Tools\codex-threadripper.exe"), + Path::new(r"C:\Codex"), + None, + Some("work"), + Duration::from_millis(500), + ); + let command = shell_run_command(&script); + + assert!(command.contains("--profile work")); + assert!(command.ends_with("watch --poll-interval-ms 500")); +} + +fn shell_run_command(script: &str) -> String { + let line = script + .lines() + .find(|line| line.starts_with("shell.Run ")) + .expect("script should contain shell.Run"); + let literal = line + .strip_prefix("shell.Run ") + .and_then(|value| value.strip_suffix(", 0, False")) + .expect("shell.Run should pass a quoted command literal"); + assert!(literal.starts_with('"') && literal.ends_with('"')); + literal[1..literal.len() - 1].replace("\"\"", "\"") +} diff --git a/src/state_db.rs b/src/state_db.rs new file mode 100644 index 0000000..f0aff25 --- /dev/null +++ b/src/state_db.rs @@ -0,0 +1,165 @@ +use anyhow::Context; +use anyhow::Result; +use rusqlite::Connection; +use rusqlite::TransactionBehavior; +use rusqlite::backup::Backup; +use rusqlite::backup::StepResult; +use std::fs; +use std::path::Path; +use std::path::PathBuf; +use std::time::Duration; +use std::time::Instant; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; + +use crate::codex_config::STATE_DB_FILENAME; +use crate::fs_sync::sync_dir; +use crate::fs_sync::sync_file; +use crate::locale::Locale; +use crate::locale::detect_locale; + +pub(crate) type ProviderDistribution = Vec<(String, u64)>; + +pub(crate) fn inspect_sqlite_distribution( + sqlite_path: &Path, + provider: &str, +) -> Result<(u64, u64, ProviderDistribution)> { + ensure_sqlite_exists(sqlite_path)?; + let connection = Connection::open(sqlite_path) + .with_context(|| format!("failed to open {}", sqlite_path.display()))?; + let total_rows = connection.query_row("SELECT COUNT(*) FROM threads", [], |row| row.get(0))?; + let mismatched_rows = connection.query_row( + "SELECT COUNT(*) FROM threads WHERE model_provider <> ?1", + [provider], + |row| row.get(0), + )?; + let mut statement = connection.prepare( + "SELECT model_provider, COUNT(*) AS row_count FROM threads GROUP BY model_provider ORDER BY row_count DESC, model_provider ASC", + )?; + let distribution = statement + .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))? + .collect::>>()?; + Ok((total_rows, mismatched_rows, distribution)) +} + +pub(crate) fn reconcile_sqlite_in_place(sqlite_path: &Path, provider: &str) -> Result<(u64, u64)> { + ensure_sqlite_exists(sqlite_path)?; + let mut connection = Connection::open(sqlite_path) + .with_context(|| format!("failed to open {}", sqlite_path.display()))?; + connection.busy_timeout(Duration::from_millis(5_000))?; + let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?; + let total_rows: u64 = + transaction.query_row("SELECT COUNT(*) FROM threads", [], |row| row.get(0))?; + let changed_rows = transaction.execute( + "UPDATE threads SET model_provider = ?1 WHERE model_provider <> ?1", + [provider], + )? as u64; + transaction.commit()?; + + Ok((changed_rows, total_rows)) +} + +#[cfg(test)] +pub(crate) fn reconcile_sqlite_with_backup( + sqlite_path: &Path, + provider: &str, +) -> Result<(u64, u64, PathBuf)> { + let backup_path = create_sqlite_backup_file(sqlite_path)?; + let (changed_rows, total_rows) = reconcile_sqlite_in_place(sqlite_path, provider)?; + Ok((changed_rows, total_rows, backup_path)) +} + +pub(crate) fn create_sqlite_backup_file(sqlite_path: &Path) -> Result { + let backups_dir = sqlite_path + .parent() + .unwrap_or_else(|| Path::new(".")) + .join("backups"); + fs::create_dir_all(&backups_dir) + .with_context(|| format!("failed to create {}", backups_dir.display()))?; + + let timestamp = unix_timestamp_millis()?; + let sqlite_name = sqlite_path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(STATE_DB_FILENAME); + let backup_name = format!("{sqlite_name}.{timestamp}.bak"); + let backup_path = backups_dir.join(&backup_name); + let backup_temp_path = backups_dir.join(format!("{backup_name}.tmp")); + + if backup_temp_path.exists() { + fs::remove_file(&backup_temp_path) + .with_context(|| format!("failed to remove {}", backup_temp_path.display()))?; + } + + create_sqlite_backup(sqlite_path, &backup_temp_path)?; + fs::rename(&backup_temp_path, &backup_path) + .with_context(|| format!("failed to finalize {}", backup_path.display()))?; + sync_dir(&backups_dir)?; + + Ok(backup_path) +} + +pub(crate) fn unix_timestamp_millis() -> Result { + Ok(SystemTime::now() + .duration_since(UNIX_EPOCH) + .context("system clock is earlier than UNIX_EPOCH")? + .as_millis()) +} + +fn create_sqlite_backup(sqlite_path: &Path, backup_path: &Path) -> Result<()> { + ensure_sqlite_exists(sqlite_path)?; + let source = Connection::open(sqlite_path) + .with_context(|| format!("failed to open {}", sqlite_path.display()))?; + source.busy_timeout(Duration::from_millis(5_000))?; + let mut destination = Connection::open(backup_path) + .with_context(|| format!("failed to open {}", backup_path.display()))?; + let backup = Backup::new(&source, &mut destination)?; + let started = Instant::now(); + let timeout = Duration::from_secs(30); + + loop { + if started.elapsed() >= timeout { + anyhow::bail!( + "sqlite backup timed out after {} seconds for {}", + timeout.as_secs(), + sqlite_path.display() + ); + } + + match backup.step(100)? { + StepResult::Done => break, + StepResult::More => {} + StepResult::Busy | StepResult::Locked => { + std::thread::sleep(Duration::from_millis(50)); + } + _ => {} + } + } + + drop(backup); + drop(destination); + sync_file(backup_path)?; + + Ok(()) +} + +pub(crate) fn ensure_sqlite_exists(sqlite_path: &Path) -> Result<()> { + if sqlite_path.exists() { + return Ok(()); + } + + anyhow::bail!(sqlite_missing_error(detect_locale(), sqlite_path)); +} + +fn sqlite_missing_error(locale: Locale, path: &Path) -> String { + match locale { + Locale::En => format!( + "database not found at {} — run Codex at least once to create it", + path.display() + ), + Locale::ZhHans => format!( + "未找到数据库 {} — 请先运行一次 Codex 以生成它", + path.display() + ), + } +} diff --git a/src/sync.rs b/src/sync.rs new file mode 100644 index 0000000..05874c2 --- /dev/null +++ b/src/sync.rs @@ -0,0 +1,192 @@ +use anyhow::Result; +use std::path::Path; +use std::path::PathBuf; +use std::time::Duration; +use std::time::Instant; + +use crate::cli::DEFAULT_BUCKET_PADDING_BYTES; +use crate::codex_config::read_provider_from_config; +use crate::codex_config::resolve_sqlite_path; +use crate::rollout::RolloutProgressConfig; +use crate::rollout::RolloutScope; +use crate::rollout::reconcile_rollout_metadata_from_sqlite_with_progress; +use crate::service; +use crate::service::ServiceStatus as BackgroundServiceStatus; +use crate::state_db::ProviderDistribution; +use crate::state_db::create_sqlite_backup_file; +use crate::state_db::inspect_sqlite_distribution; +use crate::state_db::reconcile_sqlite_in_place; + +#[derive(Debug)] +pub(crate) struct ReconcileSummary { + pub(crate) provider: String, + pub(crate) changed_rows: u64, + pub(crate) total_rows: u64, + pub(crate) changed_rollouts: u64, + pub(crate) checked_rollouts: u64, + pub(crate) prepared_rollouts: u64, + pub(crate) skipped_rollouts: u64, + pub(crate) elapsed: Duration, + pub(crate) backup_path: Option, + pub(crate) rollout_journal_path: Option, +} + +#[derive(Debug)] +pub(crate) struct StatusSummary { + pub(crate) codex_home: PathBuf, + pub(crate) sqlite_path: PathBuf, + pub(crate) config_path: PathBuf, + pub(crate) provider: String, + pub(crate) total_rows: u64, + pub(crate) mismatched_rows: u64, + pub(crate) distribution: ProviderDistribution, + pub(crate) service_status: BackgroundServiceStatus, +} + +pub(crate) fn collect_status( + codex_home: &Path, + provider_override: Option<&str>, + profile_override: Option<&str>, +) -> Result { + let config_path = codex_home.join("config.toml"); + let sqlite_path = resolve_sqlite_path(codex_home, profile_override)?; + let provider = match provider_override { + Some(provider) => provider.to_string(), + None => read_provider_from_config(codex_home, profile_override)?, + }; + let (total_rows, mismatched_rows, distribution) = + inspect_sqlite_distribution(&sqlite_path, provider.as_str())?; + let service_status = service::current_service_status()?; + + Ok(StatusSummary { + codex_home: codex_home.to_path_buf(), + sqlite_path, + config_path, + provider, + total_rows, + mismatched_rows, + distribution, + service_status, + }) +} + +pub(crate) fn reconcile_once( + codex_home: &Path, + provider_override: Option<&str>, + profile_override: Option<&str>, + rollout_scope: RolloutScope, +) -> Result { + reconcile_once_with_progress( + codex_home, + provider_override, + profile_override, + rollout_scope, + None, + ) +} + +fn reconcile_once_with_progress( + codex_home: &Path, + provider_override: Option<&str>, + profile_override: Option<&str>, + rollout_scope: RolloutScope, + progress: Option, +) -> Result { + let provider = match provider_override { + Some(provider) => provider.to_string(), + None => read_provider_from_config(codex_home, profile_override)?, + }; + let sqlite_path = resolve_sqlite_path(codex_home, profile_override)?; + let started = Instant::now(); + let rollout_summary = reconcile_rollout_metadata_from_sqlite_with_progress( + &sqlite_path, + codex_home, + provider.as_str(), + rollout_scope, + None, + DEFAULT_BUCKET_PADDING_BYTES, + progress, + )?; + let (changed_rows, total_rows) = reconcile_sqlite_in_place(&sqlite_path, provider.as_str())?; + + Ok(ReconcileSummary { + provider, + changed_rows, + total_rows, + changed_rollouts: rollout_summary.changed_files, + checked_rollouts: rollout_summary.checked_files, + prepared_rollouts: rollout_summary.prepared_files, + skipped_rollouts: rollout_summary.skipped_files, + elapsed: started.elapsed(), + backup_path: None, + rollout_journal_path: rollout_summary.journal_path, + }) +} + +pub(crate) fn reconcile_once_with_backup_progress( + codex_home: &Path, + provider_override: Option<&str>, + profile_override: Option<&str>, + rollout_scope: RolloutScope, + progress: Option, +) -> Result { + reconcile_once_with_backup_and_padding( + codex_home, + provider_override, + profile_override, + rollout_scope, + DEFAULT_BUCKET_PADDING_BYTES, + progress, + ) +} + +pub(crate) fn reconcile_once_with_backup_and_padding( + codex_home: &Path, + provider_override: Option<&str>, + profile_override: Option<&str>, + rollout_scope: RolloutScope, + padding_bytes: usize, + progress: Option, +) -> Result { + let provider = match provider_override { + Some(provider) => provider.to_string(), + None => read_provider_from_config(codex_home, profile_override)?, + }; + let sqlite_path = resolve_sqlite_path(codex_home, profile_override)?; + let started = Instant::now(); + let backup_path = create_sqlite_backup_file(&sqlite_path)?; + let rollout_journal_path = + backup_path + .parent() + .unwrap_or_else(|| Path::new(".")) + .join(format!( + "rollouts.{}.jsonl", + backup_path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("state-db.bak") + )); + let rollout_summary = reconcile_rollout_metadata_from_sqlite_with_progress( + &sqlite_path, + codex_home, + provider.as_str(), + rollout_scope, + Some(rollout_journal_path.as_path()), + padding_bytes, + progress, + )?; + let (changed_rows, total_rows) = reconcile_sqlite_in_place(&sqlite_path, provider.as_str())?; + + Ok(ReconcileSummary { + provider, + changed_rows, + total_rows, + changed_rollouts: rollout_summary.changed_files, + checked_rollouts: rollout_summary.checked_files, + prepared_rollouts: rollout_summary.prepared_files, + skipped_rollouts: rollout_summary.skipped_files, + elapsed: started.elapsed(), + backup_path: Some(backup_path), + rollout_journal_path: rollout_summary.journal_path, + }) +} diff --git a/src/tests.rs b/src/tests.rs new file mode 100644 index 0000000..94b5aae --- /dev/null +++ b/src/tests.rs @@ -0,0 +1,762 @@ +use crate::cli::APP_VERSION; +use crate::cli::Cli; +use crate::cli::Command; +use crate::cli::DEFAULT_BUCKET_PADDING_BYTES; +use crate::cli::DEFAULT_POLL_INTERVAL_MS; +use crate::cli::localized_command; +use crate::cli::validate_provider_override; +use crate::cli::validate_provider_override_args; +use crate::codex_config::read_provider_from_config; +use crate::codex_config::resolve_sqlite_home_from_config; +use crate::codex_config::resolve_sqlite_path; +use crate::locale::Locale; +use crate::locale::detect_locale_from_sources; +use crate::locale::parse_apple_languages; +use crate::locale::parse_locale_tag; +use crate::output::install_next_steps; +use crate::rollout::RolloutScope; +use crate::rollout::reconcile_rollout_metadata_from_sqlite_with_progress; +use crate::service::ServiceManager; +use crate::state_db::inspect_sqlite_distribution; +use crate::state_db::reconcile_sqlite_in_place; +use crate::state_db::reconcile_sqlite_with_backup; +use crate::sync::reconcile_once; +use crate::watch::WATCH_FULL_ROLLOUT_POLL_INTERVALS; +use crate::watch::full_watch_rollout_scope; +use crate::watch::periodic_watch_rollout_scope; +use crate::watch::watched_config_paths; +use anyhow::Result; +use clap::FromArgMatches; +use clap::error::ErrorKind; +use filetime::FileTime; +use filetime::set_file_times; +use rusqlite::Connection; +use std::fs; +use std::path::Path; +use std::path::PathBuf; +use std::time::Duration; + +#[test] +fn parses_supported_locale_tags() { + assert_eq!(parse_locale_tag("zh_CN.UTF-8"), Some(Locale::ZhHans)); + assert_eq!(parse_locale_tag("zh-Hans-CN"), Some(Locale::ZhHans)); + assert_eq!(parse_locale_tag("en_US.UTF-8"), Some(Locale::En)); + assert_eq!(parse_locale_tag("en-GB"), Some(Locale::En)); + assert_eq!(parse_locale_tag("ja_JP.UTF-8"), None); +} + +#[test] +fn parses_apple_languages_output() { + let output = "(\n \"zh-Hans-CN\",\n \"en-US\"\n)\n"; + assert_eq!(parse_apple_languages(output), Some(Locale::ZhHans)); +} + +#[test] +fn detects_locale_from_sources() { + let locale = detect_locale_from_sources(["", "zh_CN.UTF-8"], None); + assert_eq!(locale, Locale::ZhHans); + + let locale = detect_locale_from_sources([""], Some("(\n en-US\n)\n")); + assert_eq!(locale, Locale::En); +} + +#[test] +fn localizes_help_surface() { + let mut command = localized_command(Locale::ZhHans); + let mut help = Vec::new(); + command.write_long_help(&mut help).unwrap(); + let rendered = String::from_utf8(help).unwrap(); + assert!(rendered.contains("面向人的 Codex 线程历史维护工具")); + assert!(rendered.contains("用法:")); + assert!(rendered.contains("命令:")); + assert!(rendered.contains("选项:")); + assert!(rendered.contains("安装并启动当前平台上的后台服务")); + assert!(rendered.contains("强制指定 provider,跳过从 config.toml 读取 model_provider")); + assert!(rendered.contains("显示帮助信息")); + assert!(rendered.contains("显示版本号")); + + let mut command = localized_command(Locale::En); + let mut help = Vec::new(); + command.write_long_help(&mut help).unwrap(); + let rendered = String::from_utf8(help).unwrap(); + assert!(rendered.contains("human-first maintenance tool for Codex thread history")); + assert!(rendered.contains("Usage:")); + assert!(rendered.contains("Commands:")); + assert!(rendered.contains("Options:")); + assert!(rendered.contains("Install and start the platform-specific background service")); + assert!( + rendered.contains("Force a provider instead of reading model_provider from config.toml") + ); + assert!(rendered.contains("Print help")); + assert!(rendered.contains("Print version")); +} + +#[test] +fn subcommand_help_and_version_are_stable() { + let command = localized_command(Locale::En); + let help_error = command + .clone() + .try_get_matches_from(["codex-threadripper", "install-service", "--help"]) + .unwrap_err(); + assert_eq!(help_error.kind(), ErrorKind::DisplayHelp); + + let version_error = command + .try_get_matches_from(["codex-threadripper", "install-service", "--version"]) + .unwrap_err(); + assert_eq!(version_error.kind(), ErrorKind::DisplayVersion); + assert!(version_error.to_string().contains(APP_VERSION)); +} + +#[test] +fn reads_provider_from_config() -> Result<()> { + let dir = tempfile::tempdir()?; + fs::write(dir.path().join("config.toml"), "model_provider = \"vm\"\n")?; + let provider = read_provider_from_config(dir.path(), None)?; + assert_eq!(provider, "vm"); + Ok(()) +} + +#[test] +fn reads_provider_from_profile_v2_config() -> Result<()> { + let dir = tempfile::tempdir()?; + fs::write( + dir.path().join("config.toml"), + "model_provider = \"openai\"\n", + )?; + fs::write( + dir.path().join("work.config.toml"), + "model_provider = \"bedrock\"\nsqlite_home = \"work-state\"\n", + )?; + + let provider = read_provider_from_config(dir.path(), Some("work"))?; + let sqlite_path = resolve_sqlite_path(dir.path(), Some("work"))?; + + assert_eq!(provider, "bedrock"); + assert_eq!(sqlite_path, dir.path().join("work-state/state_5.sqlite")); + Ok(()) +} + +#[test] +fn reads_provider_from_selected_legacy_profile() -> Result<()> { + let dir = tempfile::tempdir()?; + fs::write( + dir.path().join("config.toml"), + r#" +model_provider = "openai" +profile = "work" + +[profiles.work] +model_provider = "local" +"#, + )?; + + let provider = read_provider_from_config(dir.path(), None)?; + + assert_eq!(provider, "local"); + Ok(()) +} + +#[test] +fn errors_when_profile_config_is_missing() -> Result<()> { + let dir = tempfile::tempdir()?; + fs::write( + dir.path().join("config.toml"), + "model_provider = \"openai\"\n", + )?; + + let err = read_provider_from_config(dir.path(), Some("missing")).unwrap_err(); + + assert!(err.to_string().contains("profile `missing` was not found")); + Ok(()) +} + +#[test] +fn falls_back_to_root_config_when_selected_profile_is_missing() -> Result<()> { + let dir = tempfile::tempdir()?; + fs::write( + dir.path().join("config.toml"), + "model_provider = \"openai\"\nsqlite_home = \"root-state\"\nprofile = \"missing\"\n", + )?; + + let provider = read_provider_from_config(dir.path(), None)?; + let sqlite_path = resolve_sqlite_path(dir.path(), None)?; + + assert_eq!(provider, "openai"); + assert_eq!(sqlite_path, dir.path().join("root-state/state_5.sqlite")); + Ok(()) +} + +#[test] +fn defaults_to_openai_without_config() -> Result<()> { + let dir = tempfile::tempdir()?; + let provider = read_provider_from_config(dir.path(), None)?; + assert_eq!(provider, "openai"); + Ok(()) +} + +#[test] +fn defaults_to_openai_with_blank_provider_in_config() -> Result<()> { + let dir = tempfile::tempdir()?; + fs::write(dir.path().join("config.toml"), "model_provider = \" \"\n")?; + let provider = read_provider_from_config(dir.path(), None)?; + assert_eq!(provider, "openai"); + Ok(()) +} + +#[test] +fn resolves_sqlite_path_from_config_sqlite_home() -> Result<()> { + let dir = tempfile::tempdir()?; + let sqlite_home = dir.path().join("custom-state"); + fs::write( + dir.path().join("config.toml"), + sqlite_home_config(&sqlite_home), + )?; + + let sqlite_path = resolve_sqlite_path(dir.path(), None)?; + + assert_eq!(sqlite_path, sqlite_home.join("state_5.sqlite")); + Ok(()) +} + +#[test] +fn resolves_sqlite_home_env_after_config() -> Result<()> { + let dir = tempfile::tempdir()?; + let current_dir = dir.path().join("project"); + + let config_wins = resolve_sqlite_home_from_config( + dir.path(), + Some("configured-state"), + Some("env-state"), + current_dir.as_path(), + ); + let env_fallback = resolve_sqlite_home_from_config( + dir.path(), + Some(" "), + Some("env-state"), + current_dir.as_path(), + ); + let default_home = + resolve_sqlite_home_from_config(dir.path(), None, None, current_dir.as_path()); + + assert_eq!(config_wins, dir.path().join("configured-state")); + assert_eq!(env_fallback, current_dir.join("env-state")); + assert_eq!(default_home, dir.path()); + Ok(()) +} + +#[test] +fn rejects_blank_provider_override() { + let err = validate_provider_override(Locale::En, Some(" ")).unwrap_err(); + assert!(err.to_string().contains("provider must contain")); +} + +#[test] +fn rejects_blank_provider_override_in_cli_args_before_help_short_circuit() { + let err = validate_provider_override_args( + Locale::En, + ["codex-threadripper", "--provider", "", "status", "--help"], + ) + .unwrap_err(); + assert!(err.to_string().contains("provider must contain")); +} + +#[test] +fn rejects_blank_profile_override_in_cli_args_before_help_short_circuit() { + let err = validate_provider_override_args( + Locale::En, + ["codex-threadripper", "--profile", "", "status", "--help"], + ) + .unwrap_err(); + assert!(err.to_string().contains("profile must contain")); +} + +#[test] +fn rejects_path_profile_override_in_cli_args_before_help_short_circuit() { + let err = validate_provider_override_args( + Locale::En, + [ + "codex-threadripper", + "--profile", + "nested/work", + "status", + "--help", + ], + ) + .unwrap_err(); + assert!(err.to_string().contains("profile must be a plain name")); +} + +#[test] +fn reconcile_once_returns_error_when_db_missing() -> Result<()> { + let dir = tempfile::tempdir()?; + let sqlite_path = dir.path().join("state_5.sqlite"); + fs::write( + dir.path().join("config.toml"), + sqlite_home_config(dir.path()), + )?; + + let err = reconcile_once(dir.path(), Some("openai"), None, RolloutScope::None).unwrap_err(); + + assert!(err.to_string().contains(&sqlite_path.display().to_string())); + assert!(!sqlite_path.exists()); + Ok(()) +} + +#[test] +fn inspect_sqlite_distribution_returns_error_when_db_missing() -> Result<()> { + let dir = tempfile::tempdir()?; + let sqlite_path = dir.path().join("state_5.sqlite"); + let backups_path = dir.path().join("backups"); + + let err = inspect_sqlite_distribution(&sqlite_path, "openai").unwrap_err(); + + assert!(err.to_string().contains(&sqlite_path.display().to_string())); + assert!(!sqlite_path.exists()); + assert!(!backups_path.exists()); + Ok(()) +} + +#[test] +fn reconciles_sqlite_rows_in_place() -> Result<()> { + let dir = tempfile::tempdir()?; + let sqlite_path = dir.path().join("state_5.sqlite"); + seed_sqlite(&sqlite_path)?; + + let (changed_rows, total_rows) = reconcile_sqlite_in_place(&sqlite_path, "openai")?; + let connection = Connection::open(&sqlite_path)?; + let other_rows: u64 = connection.query_row( + "SELECT COUNT(*) FROM threads WHERE model_provider <> 'openai'", + [], + |row| row.get(0), + )?; + + assert_eq!(changed_rows, 2); + assert_eq!(total_rows, 3); + assert_eq!(other_rows, 0); + Ok(()) +} + +#[test] +fn sync_backup_preserves_pre_reconcile_state() -> Result<()> { + let dir = tempfile::tempdir()?; + let sqlite_path = dir.path().join("state_5.sqlite"); + seed_sqlite(&sqlite_path)?; + + let (changed_rows, total_rows, backup_path) = + reconcile_sqlite_with_backup(&sqlite_path, "openai")?; + let live_connection = Connection::open(&sqlite_path)?; + let backup_connection = Connection::open(&backup_path)?; + let live_other_rows: u64 = live_connection.query_row( + "SELECT COUNT(*) FROM threads WHERE model_provider <> 'openai'", + [], + |row| row.get(0), + )?; + let backup_other_rows: u64 = backup_connection.query_row( + "SELECT COUNT(*) FROM threads WHERE model_provider <> 'openai'", + [], + |row| row.get(0), + )?; + + assert_eq!(changed_rows, 2); + assert_eq!(total_rows, 3); + assert_eq!(live_other_rows, 0); + assert_eq!(backup_other_rows, 2); + Ok(()) +} + +#[test] +fn seed_sqlite_fixture_includes_current_codex_thread_columns() -> Result<()> { + let dir = tempfile::tempdir()?; + let sqlite_path = dir.path().join("state_5.sqlite"); + seed_sqlite(&sqlite_path)?; + let connection = Connection::open(&sqlite_path)?; + + let mut statement = connection.prepare("PRAGMA table_info(threads)")?; + let columns = statement + .query_map([], |row| row.get::<_, String>(1))? + .collect::>>()?; + for column in ["created_at_ms", "updated_at_ms", "thread_source", "preview"] { + assert!(columns.iter().any(|candidate| candidate == column)); + } + + let mut statement = connection.prepare("PRAGMA index_list(threads)")?; + let indexes = statement + .query_map([], |row| row.get::<_, String>(1))? + .collect::>>()?; + for index in [ + "idx_threads_created_at_ms", + "idx_threads_updated_at_ms", + "idx_threads_archived_cwd_created_at_ms", + "idx_threads_archived_cwd_updated_at_ms", + ] { + assert!(indexes.iter().any(|candidate| candidate == index)); + } + + let mut statement = connection + .prepare("SELECT name FROM sqlite_master WHERE type = 'trigger' ORDER BY name")?; + let triggers = statement + .query_map([], |row| row.get::<_, String>(0))? + .collect::>>()?; + for trigger in [ + "threads_created_at_ms_after_insert", + "threads_updated_at_ms_after_insert", + "threads_created_at_ms_after_update", + "threads_updated_at_ms_after_update", + ] { + assert!(triggers.iter().any(|candidate| candidate == trigger)); + } + + connection.execute( + "INSERT INTO threads ( + id, rollout_path, created_at, updated_at, source, model_provider, cwd, title, + sandbox_policy, approval_mode + ) VALUES ('trigger-test', '', 2, 3, 'cli', 'openai', '/tmp', 'trigger test', + 'workspace-write', 'auto')", + [], + )?; + let triggered_ms: (i64, i64) = connection.query_row( + "SELECT created_at_ms, updated_at_ms FROM threads WHERE id = 'trigger-test'", + [], + |row| Ok((row.get(0)?, row.get(1)?)), + )?; + assert_eq!(triggered_ms, (2000, 3000)); + + let (created_at_ms, updated_at_ms, thread_source, preview): ( + i64, + i64, + String, + String, + ) = connection.query_row( + "SELECT created_at_ms, updated_at_ms, thread_source, preview FROM threads WHERE id = '1'", + [], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)), + )?; + assert_eq!((created_at_ms, updated_at_ms), (1000, 1000)); + assert_eq!(thread_source, "user"); + assert_eq!(preview, "a"); + Ok(()) +} + +#[test] +fn durable_sync_updates_matching_rollout_session_meta() -> Result<()> { + let dir = tempfile::tempdir()?; + let codex_home = dir.path(); + let sqlite_path = codex_home.join("state_5.sqlite"); + let rollout_path = codex_home.join("sessions/2026/05/07/rollout-2026-05-07T14-06-18-1.jsonl"); + fs::create_dir_all(rollout_path.parent().unwrap())?; + fs::write( + &rollout_path, + concat!( + "{\"type\":\"session_meta\",\"payload\":{\"id\":\"1\",\"model_provider\":\"cong\"}}\n", + "{\"type\":\"session_meta\",\"payload\":{\"id\":\"other\",\"model_provider\":\"cong\"}}\n", + "{\"type\":\"event_msg\",\"payload\":{\"type\":\"user_message\",\"message\":\"hi\"}}\n", + ), + )?; + let original_mtime = FileTime::from_unix_time(1_700_000_000, 0); + set_file_times(&rollout_path, original_mtime, original_mtime)?; + seed_sqlite(&sqlite_path)?; + let connection = Connection::open(&sqlite_path)?; + connection.execute( + "UPDATE threads SET rollout_path = ?1, model_provider = 'openai' WHERE id = '1'", + [rollout_path.display().to_string()], + )?; + drop(connection); + + let journal_path = codex_home.join("backups/rollouts.test.jsonl"); + let summary = reconcile_rollout_metadata_from_sqlite_with_progress( + &sqlite_path, + codex_home, + "openai", + RolloutScope::AllRows, + Some(journal_path.as_path()), + DEFAULT_BUCKET_PADDING_BYTES, + None, + )?; + + let rewritten = fs::read_to_string(&rollout_path)?; + let journal = fs::read_to_string(&journal_path)?; + assert_eq!(summary.checked_files, 1); + assert_eq!(summary.changed_files, 1); + assert!(rewritten.contains("\"id\":\"1\"")); + assert!(rewritten.contains("\"model_provider\":\"openai\"")); + assert!(rewritten.contains("\"id\":\"other\"")); + assert!(rewritten.contains("\"model_provider\":\"cong\"")); + assert!(journal.contains("\"mode\":\"rewrite_with_padding\"")); + assert!(journal.contains("cong")); + assert_rollout_mtime(&rollout_path, original_mtime)?; + Ok(()) +} + +#[test] +fn durable_sync_patches_shorter_provider_in_place() -> Result<()> { + let dir = tempfile::tempdir()?; + let codex_home = dir.path(); + let sqlite_path = codex_home.join("state_5.sqlite"); + let rollout_path = codex_home.join("sessions/2026/05/07/rollout-2026-05-07T14-06-18-1.jsonl"); + fs::create_dir_all(rollout_path.parent().unwrap())?; + fs::write( + &rollout_path, + concat!( + "{\"type\":\"session_meta\",\"payload\":{\"id\":\"1\",\"model_provider\":\"openai\"}} \n", + "{\"type\":\"event_msg\",\"payload\":{\"type\":\"user_message\",\"message\":\"hi\"}}\n", + ), + )?; + let original_mtime = FileTime::from_unix_time(1_700_000_100, 0); + set_file_times(&rollout_path, original_mtime, original_mtime)?; + seed_sqlite(&sqlite_path)?; + let connection = Connection::open(&sqlite_path)?; + connection.execute( + "UPDATE threads SET rollout_path = ?1, model_provider = 'openai' WHERE id = '1'", + [rollout_path.display().to_string()], + )?; + drop(connection); + let before_len = fs::metadata(&rollout_path)?.len(); + + let journal_path = codex_home.join("backups/rollouts.in-place.jsonl"); + let summary = reconcile_rollout_metadata_from_sqlite_with_progress( + &sqlite_path, + codex_home, + "cong", + RolloutScope::AllRows, + Some(journal_path.as_path()), + DEFAULT_BUCKET_PADDING_BYTES, + None, + )?; + + let after_len = fs::metadata(&rollout_path)?.len(); + let rewritten = fs::read_to_string(&rollout_path)?; + let journal = fs::read_to_string(&journal_path)?; + assert_eq!(summary.checked_files, 1); + assert_eq!(summary.changed_files, 1); + assert_eq!(summary.prepared_files, 0); + assert_eq!(before_len, after_len); + assert!( + rewritten + .lines() + .next() + .unwrap() + .contains("\"model_provider\":\"cong\"") + ); + assert!(journal.contains("\"mode\":\"in_place\"")); + assert_rollout_mtime(&rollout_path, original_mtime)?; + Ok(()) +} + +#[test] +fn watch_uses_five_hundred_ms_by_default() -> Result<()> { + let matches = + localized_command(Locale::En).try_get_matches_from(["codex-threadripper", "watch"])?; + let cli = Cli::from_arg_matches(&matches)?; + + match cli.command { + Command::Watch { + poll_interval_ms, .. + } => { + assert_eq!(poll_interval_ms, DEFAULT_POLL_INTERVAL_MS); + } + other => panic!("expected watch command, got {other:?}"), + } + + Ok(()) +} + +#[test] +fn watch_promotes_initial_rollout_scan_to_all_rows() { + assert_eq!( + full_watch_rollout_scope(RolloutScope::MismatchedRows), + RolloutScope::AllRows + ); + assert_eq!( + full_watch_rollout_scope(RolloutScope::None), + RolloutScope::None + ); +} + +#[test] +fn watch_periodically_runs_full_rollout_scan() { + assert_eq!( + periodic_watch_rollout_scope(RolloutScope::MismatchedRows, 1), + RolloutScope::MismatchedRows + ); + assert_eq!( + periodic_watch_rollout_scope( + RolloutScope::MismatchedRows, + WATCH_FULL_ROLLOUT_POLL_INTERVALS + ), + RolloutScope::AllRows + ); + assert_eq!( + periodic_watch_rollout_scope(RolloutScope::None, WATCH_FULL_ROLLOUT_POLL_INTERVALS), + RolloutScope::None + ); +} + +#[test] +fn watch_tracks_selected_profile_config_file() -> Result<()> { + let dir = tempfile::tempdir()?; + fs::write( + dir.path().join("config.toml"), + "model_provider = \"openai\"\nprofile = \"work\"\n", + )?; + + let implicit = watched_config_paths(dir.path(), None); + let explicit = watched_config_paths(dir.path(), Some("other")); + + assert!(implicit.contains(&dir.path().join("config.toml"))); + assert!(implicit.contains(&dir.path().join("work.config.toml"))); + assert!(explicit.contains(&dir.path().join("other.config.toml"))); + Ok(()) +} + +#[test] +fn generates_launchd_plist() { + let plist = crate::service::build_launchd_plist( + PathBuf::from("/tmp/codex-threadripper").as_path(), + PathBuf::from("/tmp/codex-home").as_path(), + Some("openai"), + Some("work"), + Duration::from_millis(DEFAULT_POLL_INTERVAL_MS), + ); + assert!(plist.contains("dev.wangnov.codex-threadripper")); + assert!(plist.contains("--codex-home")); + assert!(plist.contains("--provider")); + assert!(plist.contains("--profile")); + assert!(plist.contains("watch")); +} + +#[test] +fn builds_install_next_steps() -> Result<()> { + let manager = crate::service::current_manager(); + let steps = install_next_steps( + Locale::ZhHans, + PathBuf::from("/tmp/codex threadripper").as_path(), + PathBuf::from("/tmp/codex home").as_path(), + Some("openai"), + Some("work"), + manager, + )?; + assert_eq!(steps.len(), 3); + assert!(steps[0].contains("运行这条命令查看状态")); + assert!( + steps[0].contains( + "'/tmp/codex threadripper' --codex-home '/tmp/codex home' --provider openai --profile work status" + ) + ); + assert!(steps[1].contains(crate::service::manager_name(manager))); + match manager { + ServiceManager::WindowsStartup => assert!(steps[2].contains("Get-Content")), + _ => assert!(steps[2].contains("tail -f")), + } + Ok(()) +} + +fn seed_sqlite(path: &Path) -> Result<()> { + let connection = Connection::open(path)?; + connection.execute_batch( + " + CREATE TABLE threads ( + id TEXT PRIMARY KEY, + rollout_path TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + created_at_ms INTEGER, + updated_at_ms INTEGER, + source TEXT NOT NULL, + thread_source TEXT, + model_provider TEXT NOT NULL, + cwd TEXT NOT NULL, + title TEXT NOT NULL, + sandbox_policy TEXT NOT NULL, + approval_mode TEXT NOT NULL, + tokens_used INTEGER NOT NULL DEFAULT 0, + has_user_event INTEGER NOT NULL DEFAULT 0, + archived INTEGER NOT NULL DEFAULT 0, + archived_at INTEGER, + git_sha TEXT, + git_branch TEXT, + git_origin_url TEXT, + cli_version TEXT NOT NULL DEFAULT '', + first_user_message TEXT NOT NULL DEFAULT '', + agent_nickname TEXT, + agent_role TEXT, + memory_mode TEXT NOT NULL DEFAULT 'enabled', + model TEXT, + reasoning_effort TEXT, + agent_path TEXT, + preview TEXT NOT NULL DEFAULT '' + ); + CREATE INDEX idx_threads_created_at ON threads(created_at DESC, id DESC); + CREATE INDEX idx_threads_updated_at ON threads(updated_at DESC, id DESC); + CREATE INDEX idx_threads_archived ON threads(archived); + CREATE INDEX idx_threads_source ON threads(source); + CREATE INDEX idx_threads_provider ON threads(model_provider); + CREATE INDEX idx_threads_created_at_ms ON threads(created_at_ms DESC, id DESC); + CREATE INDEX idx_threads_updated_at_ms ON threads(updated_at_ms DESC, id DESC); + CREATE INDEX idx_threads_archived_cwd_created_at_ms + ON threads(archived, cwd, created_at_ms DESC, id DESC); + CREATE INDEX idx_threads_archived_cwd_updated_at_ms + ON threads(archived, cwd, updated_at_ms DESC, id DESC); + CREATE TRIGGER threads_created_at_ms_after_insert + AFTER INSERT ON threads + WHEN NEW.created_at_ms IS NULL + BEGIN + UPDATE threads + SET created_at_ms = NEW.created_at * 1000 + WHERE id = NEW.id; + END; + CREATE TRIGGER threads_updated_at_ms_after_insert + AFTER INSERT ON threads + WHEN NEW.updated_at_ms IS NULL + BEGIN + UPDATE threads + SET updated_at_ms = NEW.updated_at * 1000 + WHERE id = NEW.id; + END; + CREATE TRIGGER threads_created_at_ms_after_update + AFTER UPDATE OF created_at ON threads + WHEN NEW.created_at != OLD.created_at + AND NEW.created_at_ms IS OLD.created_at_ms + BEGIN + UPDATE threads + SET created_at_ms = NEW.created_at * 1000 + WHERE id = NEW.id; + END; + CREATE TRIGGER threads_updated_at_ms_after_update + AFTER UPDATE OF updated_at ON threads + WHEN NEW.updated_at != OLD.updated_at + AND NEW.updated_at_ms IS OLD.updated_at_ms + BEGIN + UPDATE threads + SET updated_at_ms = NEW.updated_at * 1000 + WHERE id = NEW.id; + END; + INSERT INTO threads ( + id, rollout_path, created_at, updated_at, created_at_ms, updated_at_ms, + source, thread_source, model_provider, cwd, title, sandbox_policy, + approval_mode, cli_version, model, reasoning_effort, first_user_message, preview + ) VALUES + ('1', '/tmp/a', 1, 1, 1000, 1000, 'cli', 'user', 'vm', '/tmp', 'a', + 'workspace-write', 'auto', '0.0.0-test', 'gpt-5-codex', 'medium', 'a', 'a'), + ('2', '/tmp/b', 1, 1, 1001, 1001, 'cli', 'user', 'cp', '/tmp', 'b', + 'workspace-write', 'auto', '0.0.0-test', 'gpt-5-codex', 'medium', 'b', 'b'), + ('3', '/tmp/c', 1, 1, 1002, 1002, 'cli', 'user', 'openai', '/tmp', 'c', + 'workspace-write', 'auto', '0.0.0-test', 'gpt-5-codex', 'medium', 'c', 'c'); + ", + )?; + Ok(()) +} + +fn sqlite_home_config(path: &Path) -> String { + let path = path + .display() + .to_string() + .replace('\\', "\\\\") + .replace('"', "\\\""); + format!("sqlite_home = \"{path}\"\n") +} + +fn assert_rollout_mtime(path: &Path, expected: FileTime) -> Result<()> { + let actual = FileTime::from_last_modification_time(&fs::metadata(path)?); + assert_eq!(actual.unix_seconds(), expected.unix_seconds()); + Ok(()) +} diff --git a/src/watch.rs b/src/watch.rs new file mode 100644 index 0000000..6dfd63f --- /dev/null +++ b/src/watch.rs @@ -0,0 +1,210 @@ +use anyhow::Context; +use anyhow::Result; +use fs2::FileExt; +use notify::Config as NotifyConfig; +use notify::EventKind; +use notify::RecommendedWatcher; +use notify::RecursiveMode; +use notify::Watcher; +use std::fs::OpenOptions; +use std::path::Path; +use std::path::PathBuf; +use std::sync::Arc; +use std::sync::atomic::AtomicBool; +use std::sync::atomic::Ordering; +use std::sync::mpsc; +use std::time::Duration; +use std::time::Instant; + +use crate::codex_config::selected_profile_config_path; +use crate::locale::Locale; +use crate::output::background_reconcile_title; +use crate::output::config_change_title; +use crate::output::print_sync_summary; +use crate::output::watch_already_running_error; +use crate::output::watch_initial_reconcile_error_message; +use crate::output::watch_reconcile_skipped_message; +use crate::output::watch_running_message; +use crate::output::watch_started_title; +use crate::output::watch_stopped_message; +use crate::output::watcher_disconnected_error; +use crate::output::watcher_error_message; +use crate::rollout::RolloutScope; +use crate::sync::reconcile_once; + +pub(crate) const WATCH_FULL_ROLLOUT_POLL_INTERVALS: u64 = 120; + +pub(crate) fn run_watch( + locale: Locale, + codex_home: &Path, + provider_override: Option, + profile_override: Option, + rollout_scope: RolloutScope, + poll_interval: Duration, +) -> Result<()> { + let lock_path = codex_home.join("watch.lock"); + let watch_lock = OpenOptions::new() + .create(true) + .truncate(false) + .read(true) + .write(true) + .open(&lock_path) + .with_context(|| format!("failed to open {}", lock_path.display()))?; + if let Err(err) = watch_lock.try_lock_exclusive() { + if err.kind() == std::io::ErrorKind::WouldBlock { + anyhow::bail!(watch_already_running_error(locale)); + } + return Err(err).with_context(|| format!("failed to lock {}", lock_path.display())); + } + + let config_path = codex_home.join("config.toml"); + let mut watched_paths = watched_config_paths(codex_home, profile_override.as_deref()); + let full_rollout_scope = full_watch_rollout_scope(rollout_scope); + let shutdown = Arc::new(AtomicBool::new(false)); + let shutdown_for_handler = Arc::clone(&shutdown); + ctrlc::set_handler(move || { + shutdown_for_handler.store(true, Ordering::Relaxed); + })?; + + let (tx, rx) = mpsc::channel(); + let mut watcher = RecommendedWatcher::new( + move |event| { + let _ = tx.send(event); + }, + NotifyConfig::default(), + )?; + + if let Some(parent) = config_path.parent() + && parent.exists() + { + watcher.watch(parent, RecursiveMode::NonRecursive)?; + } + + let mut last_provider = None; + match reconcile_once( + codex_home, + provider_override.as_deref(), + profile_override.as_deref(), + full_rollout_scope, + ) { + Ok(summary) => { + print_sync_summary(locale, watch_started_title(locale), &summary); + last_provider = Some(summary.provider.clone()); + } + Err(err) => { + eprintln!("{}", watch_initial_reconcile_error_message(locale, &err)); + } + } + println!( + "{}", + watch_running_message(locale, codex_home, poll_interval) + ); + + let mut next_poll_deadline = Instant::now() + poll_interval; + let mut poll_count = 0_u64; + + while !shutdown.load(Ordering::Relaxed) { + let timeout = next_poll_deadline.saturating_duration_since(Instant::now()); + match rx.recv_timeout(timeout) { + Ok(Ok(event)) => { + if touches_config_file(&event, watched_paths.as_slice()) { + match reconcile_once( + codex_home, + provider_override.as_deref(), + profile_override.as_deref(), + full_rollout_scope, + ) { + Ok(summary) => { + if last_provider.as_deref() != Some(summary.provider.as_str()) + || summary.changed_rows > 0 + || summary.changed_rollouts > 0 + { + print_sync_summary(locale, config_change_title(locale), &summary); + } + last_provider = Some(summary.provider.clone()); + watched_paths = + watched_config_paths(codex_home, profile_override.as_deref()); + } + Err(err) => { + eprintln!("{}", watch_reconcile_skipped_message(locale, &err)); + } + } + next_poll_deadline = Instant::now() + poll_interval; + } + } + Ok(Err(err)) => { + eprintln!("{}", watcher_error_message(locale, err)); + } + Err(mpsc::RecvTimeoutError::Timeout) => { + poll_count = poll_count.wrapping_add(1); + match reconcile_once( + codex_home, + provider_override.as_deref(), + profile_override.as_deref(), + periodic_watch_rollout_scope(rollout_scope, poll_count), + ) { + Ok(summary) => { + if last_provider.as_deref() != Some(summary.provider.as_str()) + || summary.changed_rows > 0 + || summary.changed_rollouts > 0 + { + print_sync_summary( + locale, + background_reconcile_title(locale), + &summary, + ); + } + last_provider = Some(summary.provider.clone()); + } + Err(err) => { + eprintln!("{}", watch_reconcile_skipped_message(locale, &err)); + } + } + next_poll_deadline = Instant::now() + poll_interval; + } + Err(mpsc::RecvTimeoutError::Disconnected) => { + anyhow::bail!(watcher_disconnected_error(locale)); + } + } + } + println!("{}", watch_stopped_message(locale)); + Ok(()) +} + +pub(crate) fn periodic_watch_rollout_scope( + rollout_scope: RolloutScope, + poll_count: u64, +) -> RolloutScope { + if poll_count.is_multiple_of(WATCH_FULL_ROLLOUT_POLL_INTERVALS) { + return full_watch_rollout_scope(rollout_scope); + } + rollout_scope +} + +pub(crate) fn full_watch_rollout_scope(rollout_scope: RolloutScope) -> RolloutScope { + match rollout_scope { + RolloutScope::None => RolloutScope::None, + RolloutScope::MismatchedRows | RolloutScope::AllRows => RolloutScope::AllRows, + } +} + +pub(crate) fn watched_config_paths( + codex_home: &Path, + profile_override: Option<&str>, +) -> Vec { + let mut paths = vec![codex_home.join("config.toml")]; + if let Some(profile_path) = selected_profile_config_path(codex_home, profile_override) { + paths.push(profile_path); + } + paths +} + +fn touches_config_file(event: ¬ify::Event, config_paths: &[PathBuf]) -> bool { + matches!( + event.kind, + EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_) + ) && event + .paths + .iter() + .any(|path| config_paths.iter().any(|config_path| path == config_path)) +}