schwab_cli/commands/
ui_cmd.rs1use std::path::PathBuf;
2use std::sync::{Arc, Mutex};
3use std::time::Duration;
4
5use anyhow::{Context, Result};
6
7use crate::agent::{daemon_status, run_agent_loop};
8use crate::cli::RulesCommands;
9use crate::config::RuntimeConfig;
10use crate::rules::RulesConfig;
11use crate::safety::require_trading_approval;
12use crate::ui::agent_health::{new_shared_health, update_health, SharedAgentHealth};
13use crate::ui::discover::{list_rules_files_display, resolve_rules_file};
14use crate::ui::market_status::{self, MarketSnapshot};
15use crate::ui::menu::{list_rules_files, show_dashboard, show_rules};
16use crate::ui::spread_feed::{new_spread_snapshot, spawn_spread_mark_feed};
17use crate::ui::watch::{run_watch_tui, WatchAgentMode, WatchConfig};
18use crate::market_conditions::{spawn_market_conditions_feed, MarketConditionsSnapshot};
19
20pub async fn run_dashboard(runtime: &RuntimeConfig, file: Option<PathBuf>) -> Result<()> {
21 show_dashboard(runtime, file).await
22}
23
24pub async fn run_watch(
25 runtime: &RuntimeConfig,
26 file: Option<PathBuf>,
27 monitor_only: bool,
28) -> Result<()> {
29 if runtime.output == crate::output::OutputFormat::Json {
30 anyhow::bail!("watch is interactive only — omit --json");
31 }
32
33 let rules_path = resolve_rules_file(file, runtime.is_interactive())?;
34 let daemon = daemon_status(&rules_path);
35
36 let agent_mode = if daemon.running {
37 WatchAgentMode::External
38 } else if monitor_only {
39 WatchAgentMode::MonitorOnly
40 } else {
41 WatchAgentMode::Embedded
42 };
43
44 let will_spawn_agent = matches!(agent_mode, WatchAgentMode::Embedded);
45
46 if will_spawn_agent && !runtime.dry_run && !runtime.simulate {
47 let agent_id = RulesConfig::load(&rules_path)
48 .map(|r| r.agent_id)
49 .unwrap_or_else(|_| "agent".into());
50 require_trading_approval(
51 runtime,
52 "watch",
53 &format!("Run and watch options agent `{agent_id}`"),
54 )?;
55 }
56
57 let agent_runtime = RuntimeConfig::for_agent_trading(
58 runtime.output,
59 runtime.yes,
60 runtime.dry_run,
61 runtime.simulate,
62 runtime.trust,
63 true,
64 runtime.no_audio,
65 runtime.safety.clone(),
66 runtime.sink.clone(),
67 );
68
69 let market_snapshot = Arc::new(Mutex::new(MarketSnapshot::default()));
70 market_status::refresh_market_snapshot(runtime, &market_snapshot).await;
71 let snapshot_for_refresh = market_snapshot.clone();
72 let refresh_runtime = runtime.clone();
73 let _market_refresh = tokio::spawn(async move {
74 let mut interval = tokio::time::interval(Duration::from_secs(120));
75 interval.tick().await;
76 loop {
77 interval.tick().await;
78 market_status::refresh_market_snapshot(&refresh_runtime, &snapshot_for_refresh).await;
79 }
80 });
81
82 let market_conditions = Arc::new(Mutex::new(MarketConditionsSnapshot::default()));
83 if let Ok(market_api) = runtime.build_market_api() {
84 if let Ok(mut guard) = market_conditions.lock() {
85 crate::market_conditions::refresh_market_conditions(&market_api, &mut guard).await;
86 }
87 let _conditions_feed =
88 spawn_market_conditions_feed(market_api, market_conditions.clone());
89 }
90
91 let agent_health: Option<SharedAgentHealth> = if will_spawn_agent {
92 Some(new_shared_health())
93 } else {
94 None
95 };
96
97 let agent_handle = if will_spawn_agent {
98 let path = rules_path.clone();
99 let health = agent_health.clone().expect("health when spawning");
100 Some(tokio::spawn(async move {
101 supervise_options_agent(agent_runtime, path, health).await;
102 }))
103 } else {
104 None
105 };
106
107 if will_spawn_agent {
109 tokio::time::sleep(std::time::Duration::from_millis(300)).await;
110 }
111
112 let spread_snapshot = new_spread_snapshot();
113 if let (Ok(market), Ok(trader)) = (
114 runtime.build_market_api(),
115 runtime.build_api(),
116 ) {
117 let _spread_feed = spawn_spread_mark_feed(
118 rules_path.clone(),
119 runtime.simulate,
120 spread_snapshot.clone(),
121 market,
122 trader,
123 );
124 }
125
126 let watch_config = WatchConfig {
127 rules_path: rules_path.clone(),
128 agent_mode,
129 market_snapshot,
130 market_conditions,
131 agent_health,
132 spread_snapshot,
133 };
134
135 let watch_result = tokio::task::spawn_blocking(move || run_watch_tui(&watch_config))
136 .await
137 .context("watch UI thread panicked")?;
138
139 if let Some(handle) = agent_handle {
140 handle.abort();
141 let _ = handle.await;
142 }
143
144 watch_result
145}
146
147async fn supervise_options_agent(
149 rt: RuntimeConfig,
150 path: PathBuf,
151 health: SharedAgentHealth,
152) {
153 let mut restart_backoff_secs: u64 = 5;
154 loop {
155 update_health(&health, |g| {
156 g.loop_running = true;
157 });
158 let result = run_agent_loop(&rt, &path, false, Some(health.clone())).await;
159 match result {
160 Ok(()) => {
161 update_health(&health, |g| g.record_loop_stopped(None));
162 let _ = crate::agent::paths::append_agent_log(
163 &path,
164 "agent supervisor: loop returned Ok — stopping",
165 );
166 break;
167 }
168 Err(e) => {
169 let msg = format!("agent loop exited: {e:#}");
170 let _ = crate::agent::paths::append_agent_log(&path, &msg);
171 update_health(&health, |g| {
172 g.record_supervisor_restart();
173 g.record_loop_stopped(Some(msg));
174 });
175 let restarts = health.lock().map(|g| g.restart_count).unwrap_or(0);
176 let _ = crate::agent::paths::append_agent_log(
177 &path,
178 &format!(
179 "agent supervisor: restarting in {restart_backoff_secs}s (restart #{restarts})"
180 ),
181 );
182 tokio::time::sleep(Duration::from_secs(restart_backoff_secs)).await;
183 restart_backoff_secs = (restart_backoff_secs.saturating_mul(2)).min(300);
184 update_health(&health, |g| {
185 g.loop_running = true;
186 g.healthy = false;
187 });
188 }
189 }
190 }
191}
192
193pub async fn run_rules(runtime: &RuntimeConfig, command: RulesCommands) -> Result<()> {
194 match command {
195 RulesCommands::Show { file } => show_rules(runtime, file).await,
196 RulesCommands::List => {
197 if runtime.output == crate::output::OutputFormat::Json {
198 list_rules_files(runtime);
199 } else {
200 println!("\n{}", list_rules_files_display());
201 println!();
202 }
203 Ok(())
204 }
205 }
206}