Skip to main content

schwab_cli/ui/
agent_health.rs

1use std::sync::{Arc, Mutex};
2use std::time::Instant;
3
4use chrono::{DateTime, Utc};
5
6/// Shared between the embedded agent task and the watch TUI.
7#[derive(Debug, Clone)]
8pub struct AgentWatchHealth {
9    pub loop_running: bool,
10    /// Last tick succeeded; false while in error backoff.
11    pub healthy: bool,
12    pub started_at: Instant,
13    pub ticks_completed: u64,
14    pub last_error: Option<String>,
15    /// Set when Schwab OAuth cannot be refreshed — user must `schwab auth login`.
16    pub auth_required: bool,
17    pub consecutive_failures: u32,
18    pub restart_count: u32,
19    pub last_success_at: Option<DateTime<Utc>>,
20}
21
22impl AgentWatchHealth {
23    pub fn starting() -> Self {
24        Self {
25            loop_running: true,
26            healthy: false,
27            started_at: Instant::now(),
28            ticks_completed: 0,
29            last_error: None,
30            auth_required: false,
31            consecutive_failures: 0,
32            restart_count: 0,
33            last_success_at: None,
34        }
35    }
36
37    /// True when the agent can place/close option orders on the next tick.
38    pub fn exits_armed(&self) -> bool {
39        self.loop_running && self.healthy && !self.auth_required
40    }
41
42    pub fn status_label(&self) -> &'static str {
43        if !self.loop_running {
44            "stopped"
45        } else if self.auth_required {
46            "auth_down"
47        } else if !self.healthy {
48            "degraded"
49        } else {
50            "running"
51        }
52    }
53
54    pub fn record_tick(&mut self) {
55        self.ticks_completed += 1;
56        self.loop_running = true;
57        self.healthy = true;
58        self.last_error = None;
59        self.auth_required = false;
60        self.consecutive_failures = 0;
61        self.last_success_at = Some(Utc::now());
62    }
63
64    pub fn record_error(&mut self, err: &str) {
65        self.last_error = Some(err.to_string());
66        self.healthy = false;
67        self.consecutive_failures = self.consecutive_failures.saturating_add(1);
68        if is_fatal_auth_error(err) {
69            self.auth_required = true;
70            // Keep loop_running true — agent stays up and retries after re-login.
71        }
72    }
73
74    pub fn record_loop_stopped(&mut self, msg: Option<String>) {
75        self.loop_running = false;
76        self.healthy = false;
77        if let Some(m) = msg {
78            self.last_error = Some(m);
79        }
80    }
81
82    pub fn record_supervisor_restart(&mut self) {
83        self.restart_count = self.restart_count.saturating_add(1);
84        self.loop_running = true;
85        self.healthy = false;
86    }
87}
88
89pub type SharedAgentHealth = Arc<Mutex<AgentWatchHealth>>;
90
91pub fn new_shared_health() -> SharedAgentHealth {
92    Arc::new(Mutex::new(AgentWatchHealth::starting()))
93}
94
95pub fn update_health(health: &SharedAgentHealth, f: impl FnOnce(&mut AgentWatchHealth)) {
96    if let Ok(mut g) = health.lock() {
97        f(&mut g);
98    }
99}
100
101pub fn format_tick_error(err: &str) -> String {
102    if is_fatal_auth_error(err) {
103        return "Schwab login required — run: schwab auth login".to_string();
104    }
105    let needs_login = err.contains("OAuth error");
106    if needs_login && !err.contains("auth login") {
107        format!("{err} → run: schwab auth login")
108    } else {
109        err.to_string()
110    }
111}
112
113/// Delegates to the 3-tier classifier in `agent::resilience` (case-insensitive superset of
114/// the original exact-case patterns) so this crate has a single source of truth for what
115/// counts as an auth-fatal error, instead of two matchers that could silently drift apart.
116pub fn is_fatal_auth_error(err: &str) -> bool {
117    crate::agent::resilience::classify_error_message(err)
118        == crate::agent::resilience::AgentErrorClass::AuthFatal
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124
125    #[test]
126    fn detects_invalid_grant() {
127        let err = r#"OAuth error: HTTP 400: {"error":"invalid_grant"}"#;
128        assert!(is_fatal_auth_error(err));
129        assert!(format_tick_error(err).contains("schwab auth login"));
130    }
131
132    #[test]
133    fn auth_error_keeps_loop_running_but_disarms_exits() {
134        let mut h = AgentWatchHealth::starting();
135        h.record_tick();
136        assert!(h.exits_armed());
137        h.record_error(r#"OAuth error: invalid_grant"#);
138        assert!(h.loop_running);
139        assert!(h.auth_required);
140        assert!(!h.exits_armed());
141        assert_eq!(h.status_label(), "auth_down");
142    }
143}