Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/desktop-tauri/src-tauri/src/commands/bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -659,6 +659,7 @@ pub struct SettingsSnapshot {
provider_usage_thresholds:
std::collections::HashMap<String, codexbar::settings::UsageThresholdOverride>,
predictive_pace_warning_enabled: bool,
credential_expiry_notifications_enabled: bool,
show_pace: bool,
tray_icon_mode: &'static str,
switcher_shows_icons: bool,
Expand Down Expand Up @@ -781,6 +782,8 @@ impl From<Settings> for SettingsSnapshot {
critical_usage_threshold: settings.critical_usage_threshold,
provider_usage_thresholds: settings.provider_usage_thresholds,
predictive_pace_warning_enabled: settings.predictive_pace_warning_enabled,
credential_expiry_notifications_enabled: settings
.credential_expiry_notifications_enabled,
show_pace: settings.show_pace,
tray_icon_mode: tray_icon_mode_label(settings.tray_icon_mode),
switcher_shows_icons: settings.switcher_shows_icons,
Expand Down
135 changes: 108 additions & 27 deletions apps/desktop-tauri/src-tauri/src/commands/codex_accounts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,16 @@ use uuid::Uuid;

use codexbar::codex_accounts::{
AccountStore, CodexAccount, CodexAccountApi, CodexAccountManager, CodexAccountManagerError,
CodexAccountRuntime, CodexApiError, CodexSwitchResult, SnapshotStore, display_names_by_id,
ordinals_by_id, restart_codex_desktop,
CodexAccountRuntime, CodexAccountSource, CodexApiError, CodexSwitchResult, SnapshotStore,
display_names_by_id, ordinals_by_id, restart_codex_desktop,
};
use codexbar::notifications::CredentialAlertPolicy;
use codexbar::settings::Settings;

use crate::state::AppState;

use super::credential_alerts::FetchAttempt;
use super::providers::quota_notification_account_identity_for;
use super::*;

// ── Codex multi-account (ADR 0003, milestone 2) ──────────────────────
Expand Down Expand Up @@ -115,18 +119,18 @@ pub(crate) async fn refresh_codex_account_lanes(
return;
}

let mut handles = Vec::with_capacity(accounts.len());
let mut handles = tokio::task::JoinSet::new();
for account in accounts {
let permits = Arc::clone(&fetch_permits);
handles.push(tokio::spawn(async move {
handles.spawn(async move {
let Ok(_permit) = permits.acquire_owned().await else {
return None;
};
let api = CodexAccountApi::new();
let home_path = account.codex_home_path.clone();
let email_hint = account.email_hint.clone();
let workspace_account_id = account.effective_workspace_account_id();
match tokio::time::timeout(
let result = tokio::time::timeout(
std::time::Duration::from_secs(DEFAULT_FETCH_TIMEOUT_SECONDS),
api.fetch_snapshot_for_workspace(
&home_path,
Expand All @@ -135,57 +139,122 @@ pub(crate) async fn refresh_codex_account_lanes(
true,
),
)
.await
{
Ok(Ok(snapshot)) => Some((account, snapshot)),
Ok(Err(e)) => {
tracing::debug!(
"codex account lane {} failed: {}",
account.id,
into_api_message(e)
);
None
.await;
match result {
Ok(result) => {
if let Err(error) = &result {
tracing::debug!("codex account lane {} failed: {}", account.id, error);
}
Some((account, result))
}
Err(_) => {
tracing::debug!("codex account lane {} timed out", account.id);
None
}
}
}));
});
}

let mut updates = Vec::new();
for handle in handles {
if let Ok(Some((fetched_account, snapshot))) = handle.await {
updates.push((fetched_account, snapshot));
let mut outcomes = Vec::new();
while let Some(result) = handles.join_next().await {
if let Ok(Some(outcome)) = result {
outcomes.push(outcome);
}
}

let current_accounts = match load_codex_accounts() {
Ok(accounts) => accounts,
Err(e) => {
tracing::warn!("codex account lanes: failed to reload accounts: {e}");
return;
}
};
// Hold the generation owner through the read/merge/write so an invalidated
// batch cannot overwrite a replacement batch's account snapshots.
let state = app.state::<Mutex<AppState>>();
let Ok(state) = state.lock() else { return };
match save_codex_lane_results(&state, generation, updates) {
let Ok(mut state) = state.lock() else { return };
if !is_current_provider_refresh_generation(&state, generation) {
return;
}
let current_by_id: HashMap<Uuid, &CodexAccount> = current_accounts
.iter()
.map(|account| (account.id, account))
.collect();
let mut updates = Vec::new();
for (fetched_account, result) in outcomes {
let Some(current) = current_by_id.get(&fetched_account.id).copied() else {
continue;
};
if !account_lane_is_current(&fetched_account, current) {
continue;
}
match result {
Ok(snapshot) if account_snapshot_belongs_to(current, &snapshot) => {
observe_codex_account_lane_outcome(
&mut state.notification_manager,
&fetched_account,
FetchAttempt::Succeeded,
);
updates.push((fetched_account, snapshot));
}
Ok(_) => {}
Err(error) => observe_codex_account_lane_outcome(
&mut state.notification_manager,
&fetched_account,
FetchAttempt::Failed(error.state_kind()),
),
}
}
match save_codex_lane_results(&state, generation, updates, &current_accounts) {
Ok(false) => return,
Err(e) => tracing::warn!("codex account lanes: failed to persist snapshots: {e}"),
Ok(true) => {}
}
events::emit_codex_accounts_updated(&app);
}

fn observe_codex_account_lane_outcome(
manager: &mut codexbar::notifications::NotificationManager,
account: &CodexAccount,
attempt: FetchAttempt,
) {
if account.source != CodexAccountSource::ManagedByApp {
return;
}

let account_scope = quota_notification_account_identity_for(
ProviderId::Codex,
"",
account.email_hint.as_deref(),
None,
Some(account.id),
);
match attempt {
FetchAttempt::Succeeded => {
manager.observe_credential_recovery(ProviderId::Codex, &account_scope);
}
FetchAttempt::Failed(kind) if kind.needs_sign_in() => {
let policy = CredentialAlertPolicy::from_settings(&Settings::load());
manager.observe_credential_failure(policy, ProviderId::Codex, &account_scope, kind);
}
FetchAttempt::Failed(_) => {}
}
}

fn save_codex_lane_results(
state: &AppState,
generation: u64,
updates: Vec<(CodexAccount, codexbar::codex_accounts::AccountUsageSnapshot)>,
current_accounts: &[CodexAccount],
) -> Result<bool, std::io::Error> {
if !is_current_provider_refresh_generation(state, generation) {
return Ok(false);
}
let current_accounts = load_codex_accounts().map_err(std::io::Error::other)?;
let current_by_id: HashMap<Uuid, &CodexAccount> = current_accounts
.iter()
.map(|account| (account.id, account))
.collect();
let mut snapshots = snapshots_for_accounts(&current_accounts, SnapshotStore::new().load()?);
let mut snapshots = snapshots_for_accounts(current_accounts, SnapshotStore::new().load()?);
for (fetched_account, snapshot) in updates {
if current_by_id
.get(&fetched_account.id)
Expand Down Expand Up @@ -511,7 +580,10 @@ fn into_user_message(error: CodexAccountManagerError) -> String {

fn into_api_message(error: CodexApiError) -> String {
match error {
CodexApiError::Message(msg) => msg,
CodexApiError::AuthenticationRequired(msg)
| CodexApiError::SessionExpired(msg)
| CodexApiError::PermissionDenied(msg)
| CodexApiError::Message(msg) => msg,
CodexApiError::Network(e) => format!("network error: {e}"),
CodexApiError::Parse(e) => format!("failed to parse Codex payload: {e}"),
}
Expand Down Expand Up @@ -650,7 +722,8 @@ mod tests {
save_codex_lane_results(
&state,
state.provider_refresh_generation,
vec![(account.clone(), snapshot.clone())]
vec![(account.clone(), snapshot.clone())],
&[account.clone()]
)
.unwrap()
);
Expand All @@ -659,7 +732,15 @@ mod tests {
email: Some("old@example.com".into()),
..snapshot
};
assert!(!save_codex_lane_results(&state, old_generation, vec![(account, stale)]).unwrap());
assert!(
!save_codex_lane_results(
&state,
old_generation,
vec![(account.clone(), stale)],
&[account]
)
.unwrap()
);
assert_eq!(
std::fs::read(file_locations::snapshots_file()).unwrap(),
before
Expand Down
126 changes: 126 additions & 0 deletions apps/desktop-tauri/src-tauri/src/commands/credential_alerts.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
//! Bridges provider refresh outcomes to the core credential-expiry episode
//! tracker (`codexbar::notifications`). Classification stays typed: the
//! failure kind is captured from the fresh fetch before any last-good
//! fallback replaces the published snapshot, so cached data can neither end
//! nor restart an episode.

use codexbar::core::{ProviderId, ProviderStateKind};
use codexbar::notifications::{CredentialAlertPolicy, NotificationManager};

use super::bridge::ProviderUsageSnapshot;
use super::providers::quota_notification_account_identity;

/// Result of the fresh fetch, before last-good preservation.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum FetchAttempt {
Succeeded,
Failed(ProviderStateKind),
}

impl FetchAttempt {
pub(super) fn of(fresh: &ProviderUsageSnapshot) -> Self {
if fresh.error.is_none() {
Self::Succeeded
} else {
Self::Failed(fresh.error_state)
}
}
}

/// Report one accepted refresh outcome. `published` is the snapshot that
/// replaced the cache; `last_good` is the previous error-free snapshot, the
/// only source of account identity when the fetch itself failed.
pub(super) fn observe_attempt(
manager: &mut NotificationManager,
policy: Option<CredentialAlertPolicy>,
id: ProviderId,
token_account_id: Option<uuid::Uuid>,
attempt: FetchAttempt,
published: &ProviderUsageSnapshot,
last_good: Option<&ProviderUsageSnapshot>,
) {
match attempt {
FetchAttempt::Succeeded => {
let account = quota_notification_account_identity(published, token_account_id);
manager.observe_credential_recovery(id, &account);
}
FetchAttempt::Failed(kind) => {
if let Some(policy) = policy {
let account = quota_notification_account_identity(
last_good.unwrap_or(published),
token_account_id,
);
manager.observe_credential_failure(policy, id, &account, kind);
}
}
}
}

#[cfg(test)]
mod tests {
use super::*;
use codexbar::core::{ProviderError, instantiate_provider};
use codexbar::settings::Settings;

fn failed(kind: ProviderStateKind) -> ProviderUsageSnapshot {
ProviderUsageSnapshot::from_error(
ProviderId::Codex,
instantiate_provider(ProviderId::Codex).metadata(),
"Authentication required".to_string(),
kind,
)
}

fn succeeded() -> ProviderUsageSnapshot {
let mut snapshot = failed(ProviderStateKind::Ready);
snapshot.error = None;
snapshot.account_email = Some("person@example.test".to_string());
snapshot
}

#[test]
fn attempt_reads_the_fresh_error_state() {
assert_eq!(
FetchAttempt::of(&failed(ProviderStateKind::ExpiredSession)),
FetchAttempt::Failed(ProviderStateKind::ExpiredSession)
);
assert_eq!(FetchAttempt::of(&succeeded()), FetchAttempt::Succeeded);
}

#[test]
fn typed_state_matches_provider_error_classification() {
let kind = ProviderError::AuthRequired.state_kind();
assert_eq!(
FetchAttempt::of(&failed(kind)),
FetchAttempt::Failed(ProviderStateKind::NeedsAuthentication)
);
}

#[test]
fn observing_outcomes_with_alerts_off_never_delivers() {
// Default settings leave credential alerts off, so no toast can fire.
let policy = CredentialAlertPolicy::from_settings(&Settings::default());
let mut manager = NotificationManager::new();
let good = succeeded();
let failure = failed(ProviderStateKind::NeedsAuthentication);

observe_attempt(
&mut manager,
Some(policy),
ProviderId::Codex,
None,
FetchAttempt::of(&failure),
&failure,
Some(&good),
);
observe_attempt(
&mut manager,
Some(policy),
ProviderId::Codex,
None,
FetchAttempt::Succeeded,
&good,
None,
);
}
}
1 change: 1 addition & 0 deletions apps/desktop-tauri/src-tauri/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ mod claude_accounts;
pub(crate) mod claude_reconciliation;
mod codex_accounts;
mod codex_workspaces;
mod credential_alerts;
mod credential_detection;
mod credentials;
mod diagnostics;
Expand Down
Loading