Skip to main content

mj_controller/pollers/
credential_sync.rs

1use super::*;
2
3/// One immediate sync and notice per session per cooldown, so a harness that
4/// repeats the same failed turn does not flood the UI.
5pub const IMMEDIATE_CREDENTIAL_SYNC_COOLDOWN: Duration = Duration::from_secs(5 * 60);
6
7#[derive(Debug, Clone, PartialEq, Eq)]
8pub(super) struct PendingCredentialSync {
9    pub(super) signal: CredentialSyncSignal,
10    pub(super) profile_id: String,
11}
12
13/// Deduplicates the actor's sticky failure marker while retaining a newer
14/// failure until its session cooldown expires.
15#[derive(Debug, Default)]
16pub struct CredentialSyncSignalTracker {
17    pub(super) handled_ordinals: std::collections::BTreeMap<String, u64>,
18    pub(super) last_attempts: std::collections::BTreeMap<String, Instant>,
19    pub(super) pending: std::collections::BTreeMap<String, PendingCredentialSync>,
20}
21
22impl CredentialSyncSignalTracker {
23    pub fn observe(&mut self, session_id: &str, profile_id: &str, signal: CredentialSyncSignal) {
24        if self
25            .handled_ordinals
26            .get(session_id)
27            .is_some_and(|handled| *handled >= signal.ordinal)
28        {
29            return;
30        }
31        let pending = PendingCredentialSync {
32            signal,
33            profile_id: profile_id.to_owned(),
34        };
35        match self.pending.entry(session_id.to_owned()) {
36            std::collections::btree_map::Entry::Vacant(entry) => {
37                entry.insert(pending);
38            }
39            std::collections::btree_map::Entry::Occupied(mut entry)
40                if entry.get().signal.ordinal <= pending.signal.ordinal =>
41            {
42                entry.insert(pending);
43            }
44            std::collections::btree_map::Entry::Occupied(_) => {}
45        }
46    }
47
48    pub(super) fn drain_due(
49        &mut self,
50        now: Instant,
51    ) -> Vec<(String, String, CredentialSyncReason)> {
52        let due = self
53            .pending
54            .keys()
55            .filter(|session_id| {
56                self.last_attempts.get(*session_id).is_none_or(|previous| {
57                    now.saturating_duration_since(*previous) >= IMMEDIATE_CREDENTIAL_SYNC_COOLDOWN
58                })
59            })
60            .cloned()
61            .collect::<Vec<_>>();
62        due.into_iter()
63            .map(|session_id| {
64                let pending = self
65                    .pending
66                    .remove(&session_id)
67                    .expect("due credential sync signal disappeared");
68                self.handled_ordinals
69                    .insert(session_id.clone(), pending.signal.ordinal);
70                self.last_attempts.insert(session_id.clone(), now);
71                (session_id, pending.profile_id, pending.signal.reason)
72            })
73            .collect()
74    }
75}
76
77pub fn schedule_due_credential_syncs(
78    tracker: &mut CredentialSyncSignalTracker,
79    credential_sync: &CredentialSyncHandle,
80    now: Instant,
81) {
82    for (session_id, profile_id, reason) in tracker.drain_due(now) {
83        credential_sync.sync_profile_now(
84            &profile_id,
85            Some(CredentialSyncCause { session_id, reason }),
86        );
87    }
88}
89
90/// Turns finished credential syncs into UI notices.
91///
92/// The periodic cycle revisits every profile, so a session that keeps failing
93/// the same way would post the same notice forever. The last failure message
94/// per key is remembered and only a changed one speaks up again. Keys are the
95/// profile for a whole-sync failure and the profile plus session for a
96/// per-session failure.
97#[derive(Debug, Default)]
98pub struct CredentialSyncNotices {
99    pub(super) last_failures: std::collections::BTreeMap<(String, Option<String>), String>,
100}
101
102pub fn log_credential_sync_actions(result: &mj_core::credentials::CredentialSyncResult) {
103    let sessions = result.credential_sessions();
104    if sessions > 0 {
105        tracing::info!(
106            profile_id = %result.profile_id,
107            sessions,
108            "refreshed harness credentials"
109        );
110    }
111}
112
113/// The extra option a Claude profile has after an auth failure.
114///
115/// Claude Code cannot refresh its rotating login early, so a container copy
116/// can lose the single-use refresh race with the host. A setup token does not
117/// rotate, which takes the race away rather than retrying it.
118pub(super) fn setup_token_advice(
119    profile_id: &str,
120    harness: Option<mj_core::config::HarnessKind>,
121) -> String {
122    if harness == Some(mj_core::config::HarnessKind::Claude) {
123        format!(
124            ", or store a long-lived token with `mj login --profile {profile_id} --setup-token`"
125        )
126    } else {
127        String::new()
128    }
129}
130
131/// One credential-sync notice and whether it only reports routine syncs.
132#[derive(Debug, Clone, PartialEq, Eq)]
133pub struct CredentialNotice {
134    pub text: String,
135    pub routine: bool,
136}
137
138impl CredentialSyncNotices {
139    /// Healthy no-op cycles stay out of the UI; only actions, new failures, and
140    /// answers to an event-triggered reconciliation are worth a notice.
141    pub fn notice(
142        &mut self,
143        result: &mj_core::credentials::CredentialSyncResult,
144        harness: Option<mj_core::config::HarnessKind>,
145        state: &State,
146    ) -> Option<String> {
147        self.leveled_notice(result, harness, state)
148            .map(|notice| notice.text)
149    }
150
151    /// The notice with the one decision about how loudly to log it. A routine
152    /// summary of syncs that went well is not a warning; a failure, an
153    /// authentication problem, or anything the person has to act on is.
154    pub fn leveled_notice(
155        &mut self,
156        result: &mj_core::credentials::CredentialSyncResult,
157        harness: Option<mj_core::config::HarnessKind>,
158        state: &State,
159    ) -> Option<CredentialNotice> {
160        let warning = |text: String| CredentialNotice {
161            text,
162            routine: false,
163        };
164        let advice = setup_token_advice(&result.profile_id, harness);
165        // Completed event-triggered syncs always speak: the upstream
166        // per-session cooldown, not this dedup, is what keeps them rare.
167        if let Some(trigger) = &result.trigger {
168            let session_id = &trigger.session_id;
169            let session = state.session_notice_name(session_id);
170            let sync_failure = result.failure.as_deref().or_else(|| {
171                result.failures().find_map(|(failed_session, detail)| {
172                    (failed_session == session_id).then_some(detail)
173                })
174            });
175            if let Some(detail) = sync_failure {
176                return Some(warning(match trigger.reason {
177                    CredentialSyncReason::AuthenticationFailure => format!(
178                        "Auth failure on profile {} (session {}); credential reconciliation failed: {detail}. Run `mj login --profile {}`{advice}.",
179                        result.profile_id, session, result.profile_id
180                    ),
181                    CredentialSyncReason::EmptyPromptResponse => format!(
182                        "Session {} returned no response; credential reconciliation for profile {} failed: {detail}. The failure is recorded in the transcript.",
183                        session, result.profile_id
184                    ),
185                }));
186            }
187            // A lifecycle can defer or preempt reconciliation. With no
188            // result for this session, we have not checked its credentials.
189            if !result
190                .outcomes
191                .iter()
192                .any(|outcome| outcome.session_id == *session_id)
193            {
194                return None;
195            }
196            // The first ~80 columns are all most people read before a notice
197            // scrolls off, so the profile leads and the advice trails.
198            return Some(warning(
199                match (trigger.reason, result.pushed_to(session_id)) {
200                    (CredentialSyncReason::AuthenticationFailure, true) => format!(
201                        "Auth failure on profile {} (session {}); refreshed credentials were pushed. Retry the prompt, and if it repeats run `mj login --profile {}`{advice}.",
202                        result.profile_id, session, result.profile_id
203                    ),
204                    (CredentialSyncReason::AuthenticationFailure, false) => format!(
205                        "Auth failure on profile {} (session {}); nothing fresher to push. Run `mj login --profile {}`{advice}.",
206                        result.profile_id, session, result.profile_id
207                    ),
208                    (CredentialSyncReason::EmptyPromptResponse, true) => format!(
209                        "Session {} returned no response; fresher credentials from profile {} were pushed. Retry the prompt.",
210                        session, result.profile_id
211                    ),
212                    (CredentialSyncReason::EmptyPromptResponse, false) => format!(
213                        "Session {} returned no response; profile {} had no newer credentials to push. The failure is recorded in the transcript.",
214                        session, result.profile_id
215                    ),
216                },
217            ));
218        }
219
220        let mut failures = std::collections::BTreeMap::new();
221        if let Some(detail) = &result.failure {
222            failures.insert(
223                (result.profile_id.clone(), None),
224                format!(
225                    "Credential sync for profile {} failed: {detail}",
226                    result.profile_id
227                ),
228            );
229        }
230        for (session_id, detail) in result.failures() {
231            failures.insert(
232                (result.profile_id.clone(), Some(session_id.to_owned())),
233                format!(
234                    "Credential sync for profile {} (session {}) failed: {detail}",
235                    result.profile_id,
236                    state.session_notice_name(session_id)
237                ),
238            );
239        }
240        // A key that stopped failing is forgotten silently, so the same failure
241        // after a clean cycle is reported again.
242        self.last_failures
243            .retain(|key, _| key.0 != result.profile_id || failures.contains_key(key));
244        let mut notice = None;
245        for (key, message) in failures {
246            if self.last_failures.get(&key) != Some(&message) {
247                notice.get_or_insert_with(|| message.clone());
248            }
249            self.last_failures.insert(key, message);
250        }
251        if let Some(text) = notice {
252            return Some(warning(text));
253        }
254
255        let mut parts = Vec::new();
256        let skills = result.skills_sessions();
257        if skills > 0 {
258            parts.push(format!(
259                "Synced skills for profile {} to {skills} session(s).",
260                result.profile_id
261            ));
262        }
263        let github_pushed = result.github_token_pushed_sessions();
264        if github_pushed > 0 {
265            parts.push(format!(
266                "Synced the GitHub CLI token to {github_pushed} session(s)."
267            ));
268        }
269        let github_removed = result.github_token_removed_sessions();
270        if github_removed > 0 {
271            parts.push(format!(
272                "Removed the GitHub CLI token from {github_removed} session(s)."
273            ));
274        }
275        (!parts.is_empty()).then(|| CredentialNotice {
276            text: parts.join(" "),
277            routine: true,
278        })
279    }
280}