prodex 0.56.0

OpenAI profile pooling and safe auto-rotate for Codex CLI and Claude Code
Documentation
use super::*;

fn remap_profile_binding_targets(
    bindings: &mut BTreeMap<String, ResponseProfileBinding>,
    from_profile: &str,
    to_profile: &str,
) {
    if from_profile == to_profile {
        return;
    }
    for binding in bindings.values_mut() {
        if binding.profile_name == from_profile {
            binding.profile_name = to_profile.to_string();
        }
    }
}

fn remap_runtime_continuation_store_profiles(
    continuations: &mut RuntimeContinuationStore,
    from_profile: &str,
    to_profile: &str,
) {
    remap_profile_binding_targets(
        &mut continuations.response_profile_bindings,
        from_profile,
        to_profile,
    );
    remap_profile_binding_targets(
        &mut continuations.session_profile_bindings,
        from_profile,
        to_profile,
    );
    remap_profile_binding_targets(
        &mut continuations.turn_state_bindings,
        from_profile,
        to_profile,
    );
    remap_profile_binding_targets(
        &mut continuations.session_id_bindings,
        from_profile,
        to_profile,
    );
}

fn duplicate_profile_cleanup_priority(state: &AppState, profile_name: &str) -> (bool, i64, bool) {
    let active = state.active_profile.as_deref() == Some(profile_name);
    let last_selected_at = state
        .last_run_selected_at
        .get(profile_name)
        .copied()
        .unwrap_or(i64::MIN);
    let prefer_external = state
        .profiles
        .get(profile_name)
        .map(|profile| !profile.managed)
        .unwrap_or(false);
    (active, last_selected_at, prefer_external)
}

fn select_canonical_duplicate_profile(
    state: &AppState,
    profile_names: &[String],
) -> Option<String> {
    profile_names.iter().cloned().max_by(|left, right| {
        duplicate_profile_cleanup_priority(state, left)
            .cmp(&duplicate_profile_cleanup_priority(state, right))
            .then_with(|| right.cmp(left))
    })
}

fn resolve_cleanup_profile_emails(state: &mut AppState) -> Vec<(String, String)> {
    let jobs = state
        .profiles
        .iter()
        .map(|(name, profile)| {
            (
                name.clone(),
                profile.codex_home.clone(),
                profile
                    .email
                    .clone()
                    .filter(|email| !email.trim().is_empty()),
            )
        })
        .collect::<Vec<_>>();

    let resolved = map_parallel(jobs, |(name, codex_home, cached_email)| {
        (
            name,
            cached_email.or_else(|| fetch_profile_email(&codex_home).ok()),
        )
    });

    let mut discovered = Vec::new();
    for (name, email) in resolved {
        let Some(email) = email else {
            continue;
        };
        if let Some(profile) = state.profiles.get_mut(&name) {
            profile.email = Some(email.clone());
        }
        discovered.push((name, email));
    }
    discovered
}

pub(super) fn cleanup_duplicate_profiles(
    paths: &AppPaths,
    state: &mut AppState,
) -> Result<ProdexCleanupSummary> {
    let mut duplicates_by_email = BTreeMap::<String, Vec<String>>::new();
    for (profile_name, email) in resolve_cleanup_profile_emails(state) {
        duplicates_by_email
            .entry(normalize_email(&email))
            .or_default()
            .push(profile_name);
    }

    let duplicate_groups = duplicates_by_email
        .into_values()
        .filter(|profile_names| profile_names.len() > 1)
        .collect::<Vec<_>>();
    if duplicate_groups.is_empty() {
        return Ok(ProdexCleanupSummary::default());
    }

    let continuations_exist = runtime_continuations_file_path(paths).exists()
        || runtime_continuations_last_good_file_path(paths).exists();
    let journal_exists = runtime_continuation_journal_file_path(paths).exists()
        || runtime_continuation_journal_last_good_file_path(paths).exists();
    let mut continuations = if continuations_exist {
        Some(load_runtime_continuations_with_recovery(paths, &state.profiles)?.value)
    } else {
        None
    };
    let mut continuation_journal = if journal_exists {
        Some(load_runtime_continuation_journal_with_recovery(paths, &state.profiles)?.value)
    } else {
        None
    };

    let mut summary = ProdexCleanupSummary::default();
    for mut profile_names in duplicate_groups {
        profile_names.sort();
        let Some(canonical_name) = select_canonical_duplicate_profile(state, &profile_names) else {
            continue;
        };
        let canonical_home = state
            .profiles
            .get(&canonical_name)
            .map(|profile| profile.codex_home.clone())
            .with_context(|| format!("profile '{}' is missing during cleanup", canonical_name))?;

        for duplicate_name in profile_names
            .into_iter()
            .filter(|profile_name| profile_name != &canonical_name)
        {
            let duplicate_last_selected_at = state.last_run_selected_at.remove(&duplicate_name);
            if let Some(last_selected_at) = duplicate_last_selected_at {
                let target = state
                    .last_run_selected_at
                    .entry(canonical_name.clone())
                    .or_insert(last_selected_at);
                *target = (*target).max(last_selected_at);
            }

            remap_profile_binding_targets(
                &mut state.response_profile_bindings,
                &duplicate_name,
                &canonical_name,
            );
            remap_profile_binding_targets(
                &mut state.session_profile_bindings,
                &duplicate_name,
                &canonical_name,
            );
            if let Some(continuations) = continuations.as_mut() {
                remap_runtime_continuation_store_profiles(
                    continuations,
                    &duplicate_name,
                    &canonical_name,
                );
            }
            if let Some(journal) = continuation_journal.as_mut() {
                remap_runtime_continuation_store_profiles(
                    &mut journal.continuations,
                    &duplicate_name,
                    &canonical_name,
                );
            }

            if state.active_profile.as_deref() == Some(duplicate_name.as_str()) {
                state.active_profile = Some(canonical_name.clone());
            }

            let Some(removed_profile) = state.profiles.remove(&duplicate_name) else {
                continue;
            };
            summary.duplicate_profiles_removed += 1;

            if removed_profile.managed
                && removed_profile.codex_home.exists()
                && !same_path(&removed_profile.codex_home, &canonical_home)
            {
                fs::remove_dir_all(&removed_profile.codex_home).with_context(|| {
                    format!(
                        "failed to delete duplicate managed profile home {}",
                        removed_profile.codex_home.display()
                    )
                })?;
                summary.duplicate_managed_profile_homes_removed += 1;
            }
        }
    }

    if state.active_profile.is_none() {
        state.active_profile = state.profiles.keys().next().cloned();
    }

    if let Some(continuations) = continuations.as_ref() {
        save_runtime_continuations_for_profiles(paths, continuations, &state.profiles)?;
    }
    if let Some(journal) = continuation_journal.as_ref() {
        save_runtime_continuation_journal_for_profiles(
            paths,
            &journal.continuations,
            &state.profiles,
            journal.saved_at,
        )?;
    }

    state.save(paths)?;
    Ok(summary)
}