1use anyhow::{Context, Result};
8use serde::Deserialize;
9
10use crate::cli::ScaleArgs;
11use crate::deploy::DeploymentState;
12use crate::template::load_template;
13
14#[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
24fn 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 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, }
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 min_replicas
51 } else if current_rank >= up_rank {
52 max_replicas
54 } else {
55 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
66async 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 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 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 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 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 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 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 assert_eq!(
254 replicas_for_mode("conserving", 1, 5, "hustle", "sovereign"),
255 3 );
257 }
258}