Skip to main content

cc_switch_lib/cli/commands/
failover.rs

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 automatic failover status and queue
15    Show,
16
17    /// Enable automatic failover for the selected app
18    Enable,
19
20    /// Disable automatic failover for the selected app
21    Disable,
22
23    /// List queued failover providers
24    List,
25
26    /// List providers that can be added to the failover queue
27    Available,
28
29    /// Add a provider to the failover queue
30    Add { id: String },
31
32    /// Remove a provider from the failover queue
33    Remove { id: String },
34
35    /// Move a queued provider up or down
36    Move {
37        id: String,
38        #[arg(value_enum)]
39        direction: FailoverMoveDirection,
40    },
41
42    /// Clear the failover queue
43    Clear {
44        /// Confirm clearing the queue
45        #[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}