1use clap::{Subcommand, ValueEnum};
2
3use crate::app_config::AppType;
4use crate::cli::ui::{create_table, highlight, info, success, warning};
5use crate::database::FailoverQueueItem;
6use crate::error::AppError;
7use crate::proxy::types::ProxyTakeoverStatus;
8use crate::services::provider::ProviderSortUpdate;
9use crate::services::ProviderService;
10use crate::AppState;
11
12#[derive(Subcommand, Debug, Clone)]
13pub enum FailoverCommand {
14 Show,
16
17 Enable,
19
20 Disable,
22
23 List,
25
26 Available,
28
29 Add { id: String },
31
32 Remove { id: String },
34
35 Move {
37 id: String,
38 #[arg(value_enum)]
39 direction: FailoverMoveDirection,
40 },
41
42 Clear {
44 #[arg(long)]
46 yes: bool,
47 },
48}
49
50#[derive(ValueEnum, Debug, Clone, Copy, PartialEq, Eq)]
51pub enum FailoverMoveDirection {
52 Up,
53 Down,
54}
55
56pub fn execute(cmd: FailoverCommand, app: Option<AppType>) -> Result<(), AppError> {
57 let app_type = app.unwrap_or(AppType::Claude);
58 match cmd {
59 FailoverCommand::Show => show_failover(app_type),
60 FailoverCommand::Enable => set_auto_failover(app_type, true),
61 FailoverCommand::Disable => set_auto_failover(app_type, false),
62 FailoverCommand::List => list_queue(app_type),
63 FailoverCommand::Available => list_available(app_type),
64 FailoverCommand::Add { id } => add_provider(app_type, &id),
65 FailoverCommand::Remove { id } => remove_provider(app_type, &id),
66 FailoverCommand::Move { id, direction } => move_provider(app_type, &id, direction),
67 FailoverCommand::Clear { yes } => clear_queue(app_type, yes),
68 }
69}
70
71fn get_state() -> Result<AppState, AppError> {
72 AppState::try_new()
73}
74
75fn create_runtime() -> Result<tokio::runtime::Runtime, AppError> {
76 tokio::runtime::Builder::new_current_thread()
77 .enable_all()
78 .build()
79 .map_err(|e| AppError::Message(format!("failed to create async runtime: {e}")))
80}
81
82fn ensure_failover_supported(app_type: &AppType) -> Result<(), AppError> {
83 match app_type {
84 AppType::Claude | AppType::Codex | AppType::Gemini => Ok(()),
85 AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => Err(AppError::InvalidInput(
86 format!("failover is not supported for {}", app_type.as_str()),
87 )),
88 }
89}
90
91fn show_failover(app_type: AppType) -> Result<(), AppError> {
92 ensure_failover_supported(&app_type)?;
93 let state = get_state()?;
94 let runtime = create_runtime()?;
95 let config = runtime.block_on(state.db.get_proxy_config_for_app(app_type.as_str()))?;
96 let status = runtime.block_on(state.proxy_service.get_status());
97 let takeovers = runtime
98 .block_on(state.proxy_service.get_takeover_status())
99 .map_err(AppError::Message)?;
100 let queue = state.db.get_failover_queue(app_type.as_str())?;
101
102 println!("{}", highlight("Failover"));
103 println!("App: {}", app_type.as_str());
104 println!(
105 "Automatic failover: {}",
106 if config.auto_failover_enabled {
107 "enabled"
108 } else {
109 "disabled"
110 }
111 );
112 println!(
113 "Proxy running: {}",
114 if status.running { "yes" } else { "no" }
115 );
116 println!(
117 "Takeover active: {}",
118 if status.running && takeover_enabled_for(&takeovers, &app_type) {
119 "yes"
120 } else {
121 "no"
122 }
123 );
124 println!();
125 print_queue(&queue);
126 Ok(())
127}
128
129fn set_auto_failover(app_type: AppType, enabled: bool) -> Result<(), AppError> {
130 ensure_failover_supported(&app_type)?;
131 let state = get_state()?;
132 let runtime = create_runtime()?;
133 let queue_empty = state.db.get_failover_queue(app_type.as_str())?.is_empty();
134
135 runtime.block_on(async {
136 let mut config = state.db.get_proxy_config_for_app(app_type.as_str()).await?;
137 config.auto_failover_enabled = enabled;
138 state.db.update_proxy_config_for_app(config).await
139 })?;
140
141 println!(
142 "{}",
143 success(&format!(
144 "Automatic failover {} for {}.",
145 if enabled { "enabled" } else { "disabled" },
146 app_type.as_str()
147 ))
148 );
149 if enabled && queue_empty {
150 println!(
151 "{}",
152 warning(
153 "Add providers to the failover queue before routing traffic through the proxy."
154 )
155 );
156 }
157 print_hot_update_note();
158 Ok(())
159}
160
161fn list_queue(app_type: AppType) -> Result<(), AppError> {
162 ensure_failover_supported(&app_type)?;
163 let state = get_state()?;
164 let queue = state.db.get_failover_queue(app_type.as_str())?;
165 print_queue(&queue);
166 Ok(())
167}
168
169fn list_available(app_type: AppType) -> Result<(), AppError> {
170 ensure_failover_supported(&app_type)?;
171 let state = get_state()?;
172 let providers = state
173 .db
174 .get_available_providers_for_failover(app_type.as_str())?;
175 if providers.is_empty() {
176 println!("{}", info("No providers are available to add."));
177 return Ok(());
178 }
179
180 let mut table = create_table();
181 table.set_header(vec!["ID", "Name", "Sort"]);
182 for provider in providers {
183 table.add_row(vec![
184 provider.id,
185 provider.name,
186 provider
187 .sort_index
188 .map(|index| index.to_string())
189 .unwrap_or_else(|| "-".to_string()),
190 ]);
191 }
192 println!("{}", table);
193 Ok(())
194}
195
196fn add_provider(app_type: AppType, id: &str) -> Result<(), AppError> {
197 ensure_failover_supported(&app_type)?;
198 let state = get_state()?;
199 ensure_provider_exists(&state, &app_type, id)?;
200
201 if state.db.is_in_failover_queue(app_type.as_str(), id)? {
202 println!("{}", info("Provider is already in the failover queue."));
203 return Ok(());
204 }
205
206 state.db.add_to_failover_queue(app_type.as_str(), id)?;
207 println!("{}", success("Provider added to the failover queue."));
208 print_hot_update_note_if_running(&state)?;
209 Ok(())
210}
211
212fn remove_provider(app_type: AppType, id: &str) -> Result<(), AppError> {
213 ensure_failover_supported(&app_type)?;
214 let state = get_state()?;
215 ensure_provider_exists(&state, &app_type, id)?;
216
217 if !state.db.is_in_failover_queue(app_type.as_str(), id)? {
218 println!("{}", info("Provider is not in the failover queue."));
219 return Ok(());
220 }
221
222 if provider_is_last_active_failover_queue_entry(&state, &app_type, id)? {
223 return Err(active_proxy_failover_queue_guard_error());
224 }
225
226 state.db.remove_from_failover_queue(app_type.as_str(), id)?;
227 println!("{}", success("Provider removed from the failover queue."));
228 print_hot_update_note_if_running(&state)?;
229 Ok(())
230}
231
232fn clear_queue(app_type: AppType, yes: bool) -> Result<(), AppError> {
233 ensure_failover_supported(&app_type)?;
234 let state = get_state()?;
235 let queue = state.db.get_failover_queue(app_type.as_str())?;
236
237 if queue.is_empty() {
238 println!("{}", info("Failover queue is already empty."));
239 return Ok(());
240 }
241 if !yes {
242 return Err(AppError::InvalidInput(
243 "clearing the failover queue requires --yes".to_string(),
244 ));
245 }
246 if queue_has_active_failover_guard(&state, &app_type, &queue)? {
247 return Err(active_proxy_failover_queue_guard_error());
248 }
249
250 let runtime = create_runtime()?;
251 state.db.clear_failover_queue(app_type.as_str())?;
252 runtime.block_on(state.db.clear_provider_health_for_app(app_type.as_str()))?;
253 println!("{}", success("Failover queue cleared."));
254 print_hot_update_note_if_running(&state)?;
255 Ok(())
256}
257
258fn move_provider(
259 app_type: AppType,
260 id: &str,
261 direction: FailoverMoveDirection,
262) -> Result<(), AppError> {
263 ensure_failover_supported(&app_type)?;
264 let state = get_state()?;
265 ensure_provider_exists(&state, &app_type, id)?;
266 let outcome = move_provider_in_state(&state, app_type, id, direction)?;
267 match outcome {
268 MoveOutcome::Updated => {
269 println!("{}", success("Failover queue order updated."));
270 print_hot_update_note_if_running(&state)?;
271 }
272 MoveOutcome::NotQueued => {
273 println!(
274 "{}",
275 info("Add this provider to the failover queue before moving it.")
276 );
277 }
278 MoveOutcome::AtEdge => {
279 println!(
280 "{}",
281 info("Provider is already at the edge of the failover queue.")
282 );
283 }
284 }
285 Ok(())
286}
287
288fn move_provider_in_state(
289 state: &AppState,
290 app_type: AppType,
291 id: &str,
292 direction: FailoverMoveDirection,
293) -> Result<MoveOutcome, AppError> {
294 let mut queue = state.db.get_failover_queue(app_type.as_str())?;
295 let Some(index) = queue.iter().position(|item| item.provider_id == id) else {
296 return Ok(MoveOutcome::NotQueued);
297 };
298
299 let target = match direction {
300 FailoverMoveDirection::Up if index > 0 => index - 1,
301 FailoverMoveDirection::Down if index + 1 < queue.len() => index + 1,
302 _ => return Ok(MoveOutcome::AtEdge),
303 };
304
305 queue.swap(index, target);
306 let updates = queue
307 .iter()
308 .enumerate()
309 .map(|(sort_index, item)| ProviderSortUpdate {
310 id: item.provider_id.clone(),
311 sort_index,
312 })
313 .collect::<Vec<_>>();
314 ProviderService::update_sort_order(state, app_type, updates)?;
315 Ok(MoveOutcome::Updated)
316}
317
318#[derive(Debug, Clone, Copy, PartialEq, Eq)]
319enum MoveOutcome {
320 Updated,
321 NotQueued,
322 AtEdge,
323}
324
325fn ensure_provider_exists(state: &AppState, app_type: &AppType, id: &str) -> Result<(), AppError> {
326 state
327 .db
328 .get_provider_by_id(id, app_type.as_str())?
329 .map(|_| ())
330 .ok_or_else(|| AppError::InvalidInput(format!("Provider not found: {id}")))
331}
332
333fn provider_is_last_active_failover_queue_entry(
334 state: &AppState,
335 app_type: &AppType,
336 provider_id: &str,
337) -> Result<bool, AppError> {
338 let queue = state.db.get_failover_queue(app_type.as_str())?;
339 Ok(queue.len() == 1
340 && queue
341 .first()
342 .is_some_and(|item| item.provider_id == provider_id)
343 && active_failover_routes_app(state, app_type)?)
344}
345
346fn queue_has_active_failover_guard(
347 state: &AppState,
348 app_type: &AppType,
349 queue: &[FailoverQueueItem],
350) -> Result<bool, AppError> {
351 Ok(!queue.is_empty() && active_failover_routes_app(state, app_type)?)
352}
353
354fn active_failover_routes_app(state: &AppState, app_type: &AppType) -> Result<bool, AppError> {
355 let runtime = create_runtime()?;
356 let status = runtime.block_on(state.proxy_service.get_status());
357 if !status.running {
358 return Ok(false);
359 }
360
361 let config = runtime.block_on(state.db.get_proxy_config_for_app(app_type.as_str()))?;
362 Ok(config.enabled && config.auto_failover_enabled)
363}
364
365fn active_proxy_failover_queue_guard_error() -> AppError {
366 AppError::InvalidInput(
367 "At least one provider must remain in the failover queue while proxy failover is active."
368 .to_string(),
369 )
370}
371
372fn takeover_enabled_for(takeovers: &ProxyTakeoverStatus, app_type: &AppType) -> bool {
373 match app_type {
374 AppType::Claude => takeovers.claude,
375 AppType::Codex => takeovers.codex,
376 AppType::Gemini => takeovers.gemini,
377 AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => false,
378 }
379}
380
381fn print_queue(queue: &[FailoverQueueItem]) {
382 if queue.is_empty() {
383 println!("{}", info("Failover queue is empty."));
384 return;
385 }
386
387 let mut table = create_table();
388 table.set_header(vec!["#", "Provider ID", "Name", "Sort"]);
389 for (index, item) in queue.iter().enumerate() {
390 table.add_row(vec![
391 (index + 1).to_string(),
392 item.provider_id.clone(),
393 item.provider_name.clone(),
394 item.sort_index
395 .map(|sort_index| sort_index.to_string())
396 .unwrap_or_else(|| "-".to_string()),
397 ]);
398 }
399 println!("{}", table);
400}
401
402fn print_hot_update_note() {
403 println!(
404 "{}",
405 info("Running proxy sessions will use this on subsequent requests.")
406 );
407}
408
409fn print_hot_update_note_if_running(state: &AppState) -> Result<(), AppError> {
410 let runtime = create_runtime()?;
411 let status = runtime.block_on(state.proxy_service.get_status());
412 if status.running {
413 print_hot_update_note();
414 }
415 Ok(())
416}
417
418#[cfg(test)]
419mod tests {
420 use std::sync::{Arc, RwLock};
421
422 use crate::{Database, MultiAppConfig, ProxyService};
423
424 use super::*;
425
426 fn test_state() -> AppState {
427 let db = Arc::new(Database::memory().expect("create memory database"));
428 AppState {
429 db: db.clone(),
430 config: RwLock::new(MultiAppConfig::default()),
431 proxy_service: ProxyService::new(db),
432 }
433 }
434
435 fn provider(id: &str, name: &str, sort_index: usize) -> crate::provider::Provider {
436 let mut provider = crate::provider::Provider::with_id(
437 id.to_string(),
438 name.to_string(),
439 serde_json::json!({"api_key": "test"}),
440 Some("https://example.com".to_string()),
441 );
442 provider.sort_index = Some(sort_index);
443 provider
444 }
445
446 fn save_provider(state: &AppState, provider: crate::provider::Provider) {
447 state
448 .db
449 .save_provider("claude", &provider)
450 .expect("save provider");
451 let mut config = state.config.write().expect("lock config");
452 let manager = config
453 .get_manager_mut(&AppType::Claude)
454 .expect("claude manager");
455 manager.providers.insert(provider.id.clone(), provider);
456 }
457
458 #[test]
459 fn unsupported_apps_are_rejected() {
460 assert!(ensure_failover_supported(&AppType::OpenCode).is_err());
461 assert!(ensure_failover_supported(&AppType::OpenClaw).is_err());
462 }
463
464 #[test]
465 fn moving_non_queued_provider_is_noop() {
466 let state = test_state();
467 save_provider(&state, provider("p1", "Provider 1", 0));
468
469 let outcome =
470 move_provider_in_state(&state, AppType::Claude, "p1", FailoverMoveDirection::Down)
471 .expect("move provider");
472
473 assert_eq!(outcome, MoveOutcome::NotQueued);
474 }
475
476 #[test]
477 fn moving_provider_at_queue_edge_is_noop() {
478 let state = test_state();
479 state
480 .db
481 .save_provider("claude", &provider("p1", "Provider 1", 0))
482 .expect("save provider");
483 state
484 .db
485 .add_to_failover_queue("claude", "p1")
486 .expect("queue provider");
487
488 let outcome =
489 move_provider_in_state(&state, AppType::Claude, "p1", FailoverMoveDirection::Up)
490 .expect("move provider");
491
492 assert_eq!(outcome, MoveOutcome::AtEdge);
493 }
494
495 #[test]
496 fn moving_provider_updates_queue_order() {
497 let state = test_state();
498 save_provider(&state, provider("p1", "Provider 1", 0));
499 save_provider(&state, provider("p2", "Provider 2", 1));
500 state
501 .db
502 .add_to_failover_queue("claude", "p1")
503 .expect("queue p1");
504 state
505 .db
506 .add_to_failover_queue("claude", "p2")
507 .expect("queue p2");
508
509 let outcome =
510 move_provider_in_state(&state, AppType::Claude, "p1", FailoverMoveDirection::Down)
511 .expect("move provider");
512 let queue = state.db.get_failover_queue("claude").expect("load queue");
513
514 assert_eq!(outcome, MoveOutcome::Updated);
515 assert_eq!(queue[0].provider_id, "p2");
516 assert_eq!(queue[1].provider_id, "p1");
517 }
518}