schwab_cli/ui/
agent_health.rs1use std::sync::{Arc, Mutex};
2use std::time::Instant;
3
4#[derive(Debug, Clone)]
6pub struct AgentWatchHealth {
7 pub loop_running: bool,
8 pub started_at: Instant,
9 pub ticks_completed: u64,
10 pub last_error: Option<String>,
11 pub auth_required: bool,
13}
14
15impl AgentWatchHealth {
16 pub fn starting() -> Self {
17 Self {
18 loop_running: true,
19 started_at: Instant::now(),
20 ticks_completed: 0,
21 last_error: None,
22 auth_required: false,
23 }
24 }
25
26 pub fn record_tick(&mut self) {
27 self.ticks_completed += 1;
28 self.last_error = None;
29 self.auth_required = false;
30 }
31
32 pub fn record_error(&mut self, err: &str) {
33 self.last_error = Some(err.to_string());
34 if is_fatal_auth_error(err) {
35 self.auth_required = true;
36 self.loop_running = false;
37 }
38 }
39}
40
41pub type SharedAgentHealth = Arc<Mutex<AgentWatchHealth>>;
42
43pub fn new_shared_health() -> SharedAgentHealth {
44 Arc::new(Mutex::new(AgentWatchHealth::starting()))
45}
46
47pub fn format_tick_error(err: &str) -> String {
48 if is_fatal_auth_error(err) {
49 return "Schwab login required — run: schwab auth login".to_string();
50 }
51 let needs_login = err.contains("OAuth error");
52 if needs_login && !err.contains("auth login") {
53 format!("{err} → run: schwab auth login")
54 } else {
55 err.to_string()
56 }
57}
58
59pub fn is_fatal_auth_error(err: &str) -> bool {
60 err.contains("invalid_grant")
61 || err.contains("Refresh token is invalid")
62 || err.contains("No refresh token on disk")
63 || err.contains("Run `schwab auth login`")
64 || err.contains("Not authenticated")
65}
66
67#[cfg(test)]
68mod tests {
69 use super::*;
70
71 #[test]
72 fn detects_invalid_grant() {
73 let err = r#"OAuth error: HTTP 400: {"error":"invalid_grant"}"#;
74 assert!(is_fatal_auth_error(err));
75 assert!(format_tick_error(err).contains("schwab auth login"));
76 }
77}