1use super::*;
2
3pub 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#[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#[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
113pub(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#[derive(Debug, Clone, PartialEq, Eq)]
133pub struct CredentialNotice {
134 pub text: String,
135 pub routine: bool,
136}
137
138impl CredentialSyncNotices {
139 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 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 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 if !result
190 .outcomes
191 .iter()
192 .any(|outcome| outcome.session_id == *session_id)
193 {
194 return None;
195 }
196 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 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}