Skip to main content

life_cli/
scale.rs

1//! Auto-scaling based on Autonomic economic modes.
2//!
3//! Queries the Autonomic service for current economic state and maps
4//! economic modes (Sovereign, Conserving, Hustle, Hibernate) to replica
5//! counts using the template's scaling configuration.
6
7use anyhow::{Context, Result};
8use serde::Deserialize;
9
10use crate::cli::ScaleArgs;
11use crate::deploy::DeploymentState;
12use crate::template::load_template;
13
14/// Economic state from the Autonomic gating endpoint.
15#[derive(Debug, Deserialize)]
16struct GatingProfile {
17    economic_mode: String,
18    #[serde(default)]
19    balance_micro_credits: Option<i64>,
20    #[serde(default)]
21    monthly_burn_estimate: Option<i64>,
22}
23
24/// Determine the target replica count based on economic mode and scaling config.
25fn replicas_for_mode(
26    mode: &str,
27    min_replicas: u32,
28    max_replicas: u32,
29    scale_down_mode: &str,
30    scale_up_mode: &str,
31) -> u32 {
32    // Mode severity ordering (lowest to highest resource allocation):
33    //   Hibernate → Hustle → Conserving → Sovereign
34    let mode_rank = |m: &str| -> u32 {
35        match m.to_lowercase().as_str() {
36            "hibernate" => 0,
37            "hustle" => 1,
38            "conserving" => 2,
39            "sovereign" => 3,
40            _ => 2, // Default to conserving-level
41        }
42    };
43
44    let current_rank = mode_rank(mode);
45    let down_rank = mode_rank(scale_down_mode);
46    let up_rank = mode_rank(scale_up_mode);
47
48    if current_rank <= down_rank {
49        // At or below scale-down threshold → minimum replicas
50        min_replicas
51    } else if current_rank >= up_rank {
52        // At or above scale-up threshold → maximum replicas
53        max_replicas
54    } else {
55        // In between → linear interpolation
56        let range = max_replicas - min_replicas;
57        let position = if up_rank > down_rank {
58            (current_rank - down_rank) as f32 / (up_rank - down_rank) as f32
59        } else {
60            0.5
61        };
62        min_replicas + (range as f32 * position) as u32
63    }
64}
65
66/// Fetch current economic mode from the Autonomic service.
67async fn fetch_gating_profile(base_url: &str) -> Result<GatingProfile> {
68    let url = format!("{base_url}/gating/default");
69    let resp = reqwest::get(&url)
70        .await
71        .context("failed to reach Autonomic service")?;
72
73    if !resp.status().is_success() {
74        anyhow::bail!("Autonomic returned HTTP {}", resp.status());
75    }
76
77    resp.json()
78        .await
79        .context("failed to parse Autonomic gating profile")
80}
81
82pub async fn run(args: ScaleArgs) -> Result<()> {
83    let state = DeploymentState::load(&args.agent)
84        .with_context(|| format!("no deployment found for agent '{}'", args.agent))?;
85
86    // Load the template to get scaling configuration
87    let template = load_template(&state.template_name, None)
88        .with_context(|| format!("failed to load template '{}'", state.template_name))?;
89    let scaling = &template.scaling;
90
91    // Verify the target service exists
92    if !state.services.contains_key(&args.service) {
93        let available: Vec<&str> = state.services.keys().map(String::as_str).collect();
94        anyhow::bail!(
95            "service '{}' not found. Available: {}",
96            args.service,
97            available.join(", ")
98        );
99    }
100
101    let target_replicas = if args.auto {
102        // ── Auto-scaling: query Autonomic for economic mode ──────────────
103        let autonomic_url = state
104            .services
105            .get("autonomic")
106            .and_then(|s| s.url.as_deref());
107
108        let Some(autonomic_url) = autonomic_url else {
109            anyhow::bail!(
110                "auto-scaling requires an Autonomic service.\n\
111                 This agent template ('{}') {} an Autonomic service.\n\
112                 Use --replicas N for manual scaling instead.",
113                state.template_name,
114                if state.services.contains_key("autonomic") {
115                    "has no public URL for"
116                } else {
117                    "does not include"
118                }
119            );
120        };
121
122        println!("Querying Autonomic at {autonomic_url}...");
123
124        let profile = fetch_gating_profile(autonomic_url).await?;
125
126        let target = replicas_for_mode(
127            &profile.economic_mode,
128            scaling.min_replicas,
129            scaling.max_replicas,
130            &scaling.scale_down_mode,
131            &scaling.scale_up_mode,
132        );
133
134        println!("Economic Mode: {}", profile.economic_mode);
135        if let Some(balance) = profile.balance_micro_credits {
136            let credits = balance as f64 / 1_000_000.0;
137            println!("Balance: {credits:.2} credits");
138        }
139        if let Some(burn) = profile.monthly_burn_estimate {
140            let credits = burn as f64 / 1_000_000.0;
141            println!("Monthly Burn: {credits:.2} credits");
142        }
143        println!(
144            "Scaling Config: min={}, max={}, down_at={}, up_at={}",
145            scaling.min_replicas,
146            scaling.max_replicas,
147            scaling.scale_down_mode,
148            scaling.scale_up_mode,
149        );
150        println!();
151
152        target
153    } else if let Some(replicas) = args.replicas {
154        // ── Manual scaling ───────────────────────────────────────────────
155        if replicas < scaling.min_replicas || replicas > scaling.max_replicas {
156            eprintln!(
157                "Warning: requested {} replicas is outside template bounds ({}-{}).",
158                replicas, scaling.min_replicas, scaling.max_replicas,
159            );
160        }
161        replicas
162    } else {
163        anyhow::bail!("specify --replicas N or --auto for Autonomic-driven scaling.");
164    };
165
166    println!(
167        "Scaling {service} to {target_replicas} replica(s)...",
168        service = args.service
169    );
170
171    // Attempt to scale via the backend
172    let backend = crate::deploy::create_backend(&state.target)?;
173
174    match backend
175        .scale(&state.project_id, &args.service, target_replicas)
176        .await
177    {
178        Ok(()) => {
179            println!(
180                "Scaled {service} to {target_replicas} replica(s).",
181                service = args.service
182            );
183        }
184        Err(e) => {
185            eprintln!("Backend scaling failed: {e}");
186            eprintln!();
187            eprintln!("Manual steps:");
188            eprintln!(
189                "  1. Open the Railway dashboard for project '{}'",
190                state.project_name
191            );
192            eprintln!(
193                "  2. Navigate to service '{}' → Settings → Scaling",
194                args.service
195            );
196            eprintln!("  3. Set replicas to {target_replicas}");
197            eprintln!();
198            eprintln!(
199                "Or use the Railway CLI: railway service --id {} scale --replicas {}",
200                state
201                    .services
202                    .get(&args.service)
203                    .map(|s| s.service_id.as_str())
204                    .unwrap_or("???"),
205                target_replicas,
206            );
207        }
208    }
209
210    Ok(())
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216
217    #[test]
218    fn test_replicas_for_mode_sovereign_scales_up() {
219        assert_eq!(
220            replicas_for_mode("sovereign", 1, 5, "conserving", "sovereign"),
221            5
222        );
223    }
224
225    #[test]
226    fn test_replicas_for_mode_hibernate_scales_down() {
227        assert_eq!(
228            replicas_for_mode("hibernate", 1, 5, "conserving", "sovereign"),
229            1
230        );
231    }
232
233    #[test]
234    fn test_replicas_for_mode_conserving_at_threshold() {
235        assert_eq!(
236            replicas_for_mode("conserving", 1, 5, "conserving", "sovereign"),
237            1
238        );
239    }
240
241    #[test]
242    fn test_replicas_for_mode_hustle_interpolates() {
243        // hustle(1) is below conserving(2) = scale_down_mode → min replicas
244        assert_eq!(
245            replicas_for_mode("hustle", 2, 8, "conserving", "sovereign"),
246            2
247        );
248    }
249
250    #[test]
251    fn test_replicas_for_mode_between_thresholds() {
252        // conserving(2) between hustle(1) and sovereign(3)
253        assert_eq!(
254            replicas_for_mode("conserving", 1, 5, "hustle", "sovereign"),
255            3 // 1 + (5-1) * (2-1)/(3-1) = 1 + 4*0.5 = 3
256        );
257    }
258}