schwab_cli/ui/
agent_health.rs1use std::sync::{Arc, Mutex};
2use std::time::Instant;
3
4use chrono::{DateTime, Utc};
5
6#[derive(Debug, Clone)]
8pub struct AgentWatchHealth {
9 pub loop_running: bool,
10 pub healthy: bool,
12 pub started_at: Instant,
13 pub ticks_completed: u64,
14 pub last_error: Option<String>,
15 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 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 }
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
113pub 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}