use anyhow::{Context, Result};
use serde::Deserialize;
use crate::cli::ScaleArgs;
use crate::deploy::DeploymentState;
use crate::template::load_template;
#[derive(Debug, Deserialize)]
struct GatingProfile {
economic_mode: String,
#[serde(default)]
balance_micro_credits: Option<i64>,
#[serde(default)]
monthly_burn_estimate: Option<i64>,
}
fn replicas_for_mode(
mode: &str,
min_replicas: u32,
max_replicas: u32,
scale_down_mode: &str,
scale_up_mode: &str,
) -> u32 {
let mode_rank = |m: &str| -> u32 {
match m.to_lowercase().as_str() {
"hibernate" => 0,
"hustle" => 1,
"conserving" => 2,
"sovereign" => 3,
_ => 2, }
};
let current_rank = mode_rank(mode);
let down_rank = mode_rank(scale_down_mode);
let up_rank = mode_rank(scale_up_mode);
if current_rank <= down_rank {
min_replicas
} else if current_rank >= up_rank {
max_replicas
} else {
let range = max_replicas - min_replicas;
let position = if up_rank > down_rank {
(current_rank - down_rank) as f32 / (up_rank - down_rank) as f32
} else {
0.5
};
min_replicas + (range as f32 * position) as u32
}
}
async fn fetch_gating_profile(base_url: &str) -> Result<GatingProfile> {
let url = format!("{base_url}/gating/default");
let resp = reqwest::get(&url)
.await
.context("failed to reach Autonomic service")?;
if !resp.status().is_success() {
anyhow::bail!("Autonomic returned HTTP {}", resp.status());
}
resp.json()
.await
.context("failed to parse Autonomic gating profile")
}
pub async fn run(args: ScaleArgs) -> Result<()> {
let state = DeploymentState::load(&args.agent)
.with_context(|| format!("no deployment found for agent '{}'", args.agent))?;
let template = load_template(&state.template_name, None)
.with_context(|| format!("failed to load template '{}'", state.template_name))?;
let scaling = &template.scaling;
if !state.services.contains_key(&args.service) {
let available: Vec<&str> = state.services.keys().map(String::as_str).collect();
anyhow::bail!(
"service '{}' not found. Available: {}",
args.service,
available.join(", ")
);
}
let target_replicas = if args.auto {
let autonomic_url = state
.services
.get("autonomic")
.and_then(|s| s.url.as_deref());
let Some(autonomic_url) = autonomic_url else {
anyhow::bail!(
"auto-scaling requires an Autonomic service.\n\
This agent template ('{}') {} an Autonomic service.\n\
Use --replicas N for manual scaling instead.",
state.template_name,
if state.services.contains_key("autonomic") {
"has no public URL for"
} else {
"does not include"
}
);
};
println!("Querying Autonomic at {autonomic_url}...");
let profile = fetch_gating_profile(autonomic_url).await?;
let target = replicas_for_mode(
&profile.economic_mode,
scaling.min_replicas,
scaling.max_replicas,
&scaling.scale_down_mode,
&scaling.scale_up_mode,
);
println!("Economic Mode: {}", profile.economic_mode);
if let Some(balance) = profile.balance_micro_credits {
let credits = balance as f64 / 1_000_000.0;
println!("Balance: {credits:.2} credits");
}
if let Some(burn) = profile.monthly_burn_estimate {
let credits = burn as f64 / 1_000_000.0;
println!("Monthly Burn: {credits:.2} credits");
}
println!(
"Scaling Config: min={}, max={}, down_at={}, up_at={}",
scaling.min_replicas,
scaling.max_replicas,
scaling.scale_down_mode,
scaling.scale_up_mode,
);
println!();
target
} else if let Some(replicas) = args.replicas {
if replicas < scaling.min_replicas || replicas > scaling.max_replicas {
eprintln!(
"Warning: requested {} replicas is outside template bounds ({}-{}).",
replicas, scaling.min_replicas, scaling.max_replicas,
);
}
replicas
} else {
anyhow::bail!("specify --replicas N or --auto for Autonomic-driven scaling.");
};
println!(
"Scaling {service} to {target_replicas} replica(s)...",
service = args.service
);
let backend = crate::deploy::create_backend(&state.target)?;
match backend
.scale(&state.project_id, &args.service, target_replicas)
.await
{
Ok(()) => {
println!(
"Scaled {service} to {target_replicas} replica(s).",
service = args.service
);
}
Err(e) => {
eprintln!("Backend scaling failed: {e}");
eprintln!();
eprintln!("Manual steps:");
eprintln!(
" 1. Open the Railway dashboard for project '{}'",
state.project_name
);
eprintln!(
" 2. Navigate to service '{}' → Settings → Scaling",
args.service
);
eprintln!(" 3. Set replicas to {target_replicas}");
eprintln!();
eprintln!(
"Or use the Railway CLI: railway service --id {} scale --replicas {}",
state
.services
.get(&args.service)
.map(|s| s.service_id.as_str())
.unwrap_or("???"),
target_replicas,
);
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_replicas_for_mode_sovereign_scales_up() {
assert_eq!(
replicas_for_mode("sovereign", 1, 5, "conserving", "sovereign"),
5
);
}
#[test]
fn test_replicas_for_mode_hibernate_scales_down() {
assert_eq!(
replicas_for_mode("hibernate", 1, 5, "conserving", "sovereign"),
1
);
}
#[test]
fn test_replicas_for_mode_conserving_at_threshold() {
assert_eq!(
replicas_for_mode("conserving", 1, 5, "conserving", "sovereign"),
1
);
}
#[test]
fn test_replicas_for_mode_hustle_interpolates() {
assert_eq!(
replicas_for_mode("hustle", 2, 8, "conserving", "sovereign"),
2
);
}
#[test]
fn test_replicas_for_mode_between_thresholds() {
assert_eq!(
replicas_for_mode("conserving", 1, 5, "hustle", "sovereign"),
3 );
}
}