use anyhow::{Result, bail};
use chrono::{DateTime, Utc};
use crate::client::post_graphql;
use crate::config::Configs;
use crate::gql::{mutations, queries};
const READY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(180);
const POLL_INTERVAL_FAST: std::time::Duration = std::time::Duration::from_millis(400);
const POLL_FAST_WINDOW: std::time::Duration = std::time::Duration::from_secs(20);
const POLL_INTERVAL: std::time::Duration = std::time::Duration::from_secs(2);
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Status {
Running,
Sleeping,
Starting,
Crashed,
Failed,
Deleting,
Unknown(String),
}
impl Status {
pub fn label(&self) -> String {
match self {
Status::Running => "running".into(),
Status::Sleeping => "sleeping".into(),
Status::Starting => "starting".into(),
Status::Crashed => "crashed".into(),
Status::Failed => "failed".into(),
Status::Deleting => "deleting".into(),
Status::Unknown(s) => s.to_lowercase(),
}
}
pub fn is_live(&self) -> bool {
matches!(self, Status::Running | Status::Sleeping | Status::Starting)
}
pub fn from_label(label: &str) -> Self {
match label {
"running" => Self::Running,
"sleeping" => Self::Sleeping,
"starting" => Self::Starting,
"crashed" => Self::Crashed,
"failed" => Self::Failed,
"deleting" => Self::Deleting,
other => Self::Unknown(other.to_owned()),
}
}
}
macro_rules! status_from {
($($path:path),+ $(,)?) => {
$(
impl From<$path> for Status {
fn from(status: $path) -> Self {
use $path as S;
match status {
S::RUNNING => Status::Running,
S::SLEEPING => Status::Sleeping,
S::STARTING => Status::Starting,
S::CRASHED => Status::Crashed,
S::FAILED => Status::Failed,
S::DELETING => Status::Deleting,
S::Other(other) => Status::Unknown(other),
}
}
}
)+
};
}
status_from!(
queries::cloud_agent::CloudAgentStatus,
queries::cloud_agents::CloudAgentStatus,
queries::my_cloud_agents::CloudAgentStatus,
mutations::cloud_agent_create::CloudAgentStatus,
);
#[derive(Clone, Debug)]
pub struct Agent {
pub id: String,
pub name: String,
pub status: Status,
pub project_id: String,
pub environment_id: String,
pub created_at: DateTime<Utc>,
}
pub async fn get(
client: &reqwest::Client,
backboard: &str,
environment_id: &str,
id: &str,
) -> Result<Option<Agent>> {
let res = post_graphql::<queries::CloudAgent, _>(
client,
backboard,
queries::cloud_agent::Variables {
id: id.to_owned(),
environment_id: environment_id.to_owned(),
},
)
.await?;
Ok(res.cloud_agent.map(|a| Agent {
id: a.id,
name: a.name,
status: a.status.into(),
project_id: a.project_id,
environment_id: a.environment_id,
created_at: a.created_at,
}))
}
pub async fn list_mine(client: &reqwest::Client, backboard: &str) -> Result<Vec<Agent>> {
let res = post_graphql::<queries::MyCloudAgents, _>(
client,
backboard,
queries::my_cloud_agents::Variables {},
)
.await?;
Ok(res
.my_cloud_agents
.into_iter()
.map(|a| Agent {
id: a.id,
name: a.name,
status: a.status.into(),
project_id: a.project_id,
environment_id: a.environment_id,
created_at: a.created_at,
})
.collect())
}
pub async fn list_in_environment(
client: &reqwest::Client,
backboard: &str,
environment_id: &str,
mine: bool,
) -> Result<Vec<Agent>> {
let res = post_graphql::<queries::CloudAgents, _>(
client,
backboard,
queries::cloud_agents::Variables {
environment_id: environment_id.to_owned(),
mine: Some(mine),
},
)
.await?;
Ok(res
.cloud_agents
.into_iter()
.map(|a| Agent {
id: a.id,
name: a.name,
status: a.status.into(),
project_id: a.project_id,
environment_id: a.environment_id,
created_at: a.created_at,
})
.collect())
}
pub fn with_default_variables(variables: Option<serde_json::Value>) -> Option<serde_json::Value> {
let mut map = match variables {
None => serde_json::Map::new(),
Some(serde_json::Value::Object(map)) => map,
Some(other) => return Some(other),
};
map.entry("SHELL")
.or_insert_with(|| serde_json::Value::String("/bin/bash".to_owned()));
Some(serde_json::Value::Object(map))
}
pub async fn create(
client: &reqwest::Client,
backboard: &str,
environment_id: &str,
name: Option<String>,
variables: Option<serde_json::Value>,
) -> Result<Agent> {
let res = post_graphql::<mutations::CloudAgentCreate, _>(
client,
backboard,
mutations::cloud_agent_create::Variables {
input: mutations::cloud_agent_create::CloudAgentCreateInput {
environment_id: environment_id.to_owned(),
name,
variables: with_default_variables(variables),
},
},
)
.await?
.cloud_agent_create;
Ok(Agent {
id: res.id,
name: res.name,
status: res.status.into(),
project_id: res.project_id,
environment_id: res.environment_id,
created_at: res.created_at,
})
}
pub async fn wake(client: &reqwest::Client, backboard: &str, id: &str) -> Result<()> {
post_graphql::<mutations::CloudAgentWake, _>(
client,
backboard,
mutations::cloud_agent_wake::Variables { id: id.to_owned() },
)
.await?;
Ok(())
}
pub async fn sleep(
client: &reqwest::Client,
backboard: &str,
environment_id: &str,
id: &str,
) -> Result<()> {
crate::commands::code::flush_disk(environment_id, id).await;
sleep_without_flush(client, backboard, id).await
}
async fn sleep_without_flush(client: &reqwest::Client, backboard: &str, id: &str) -> Result<()> {
post_graphql::<mutations::CloudAgentSleep, _>(
client,
backboard,
mutations::cloud_agent_sleep::Variables { id: id.to_owned() },
)
.await?;
Ok(())
}
pub async fn delete(client: &reqwest::Client, backboard: &str, id: &str) -> Result<()> {
post_graphql::<mutations::CloudAgentDelete, _>(
client,
backboard,
mutations::cloud_agent_delete::Variables { id: id.to_owned() },
)
.await?;
Ok(())
}
pub async fn wait_until_running(
client: &reqwest::Client,
backboard: &str,
environment_id: &str,
id: &str,
) -> Result<Agent> {
let started = std::time::Instant::now();
let deadline = started + READY_TIMEOUT;
loop {
let agent = match get(client, backboard, environment_id, id).await? {
Some(agent) => agent,
None => bail!("Agent {id} disappeared while starting."),
};
match agent.status {
Status::Running => return Ok(agent),
Status::Starting | Status::Sleeping => {}
Status::Crashed => bail!("Agent {} crashed while starting.", agent.name),
Status::Failed => bail!("Agent {} failed to start.", agent.name),
Status::Deleting => bail!("Agent {} is being deleted.", agent.name),
Status::Unknown(ref s) => bail!("Agent {} is in an unknown state ({s}).", agent.name),
}
if std::time::Instant::now() >= deadline {
bail!(
"Agent {} did not reach running within {}s (last state: {}).",
agent.name,
READY_TIMEOUT.as_secs(),
agent.status.label()
);
}
let interval = if started.elapsed() < POLL_FAST_WINDOW {
POLL_INTERVAL_FAST
} else {
POLL_INTERVAL
};
tokio::time::sleep(interval).await;
}
}
pub struct ConsoleSession {
pub name: String,
pub command: String,
pub running: bool,
pub attached: bool,
}
pub async fn list_sessions(
client: &reqwest::Client,
backboard: &str,
agent_id: &str,
) -> Result<Vec<ConsoleSession>> {
let res = post_graphql::<queries::CloudAgentConsoleSessions, _>(
client,
backboard,
queries::cloud_agent_console_sessions::Variables {
cloud_agent_id: agent_id.to_owned(),
},
)
.await?;
Ok(res
.cloud_agent_console_sessions
.map(|conn| {
conn.edges
.into_iter()
.map(|edge| ConsoleSession {
name: edge.node.name,
command: edge.node.command,
running: edge.node.run_state.running,
attached: edge.node.attached,
})
.collect()
})
.unwrap_or_default())
}
pub enum Resolution {
Named,
Remembered,
Sole,
}
pub async fn resolve(
configs: &Configs,
client: &reqwest::Client,
selector: Option<&str>,
environment_id: Option<&str>,
) -> Result<(Agent, Resolution)> {
match resolve_or_none(configs, client, selector, environment_id).await? {
Some(found) => Ok(found),
None => bail!(
"You have no cloud agents{}. Create one with `railway ca create`.",
match environment_id {
Some(_) => " in this environment",
None => "",
}
),
}
}
pub async fn resolve_or_none(
configs: &Configs,
client: &reqwest::Client,
selector: Option<&str>,
environment_id: Option<&str>,
) -> Result<Option<(Agent, Resolution)>> {
let backboard = configs.get_backboard();
let candidates = match environment_id {
Some(env) => list_in_environment(client, &backboard, env, true).await?,
None => list_mine(client, &backboard).await?,
};
if let Some(selector) = selector {
return match_selector(candidates, selector).map(|agent| Some((agent, Resolution::Named)));
}
for env in configs.code_agent_environments() {
if environment_id.is_some_and(|scope| scope != env) {
continue;
}
if let Some(id) = configs.get_code_agent(&env)
&& !candidates
.iter()
.any(|a| a.environment_id == env && a.id == id)
{
bail!(
"Remembered agent {id} is unavailable in this scope. Check `railway ca list` and name an agent explicitly, or use `railway ca create` to create a separate agent."
);
}
}
let mut remembered: Vec<Agent> = candidates
.iter()
.filter(|a| configs.get_code_agent(&a.environment_id).as_deref() == Some(a.id.as_str()))
.cloned()
.collect();
if remembered.len() == 1 {
return Ok(Some((remembered.remove(0), Resolution::Remembered)));
}
match candidates.len() {
0 => Ok(None),
1 => Ok(Some((
candidates.into_iter().next().expect("len checked"),
Resolution::Sole,
))),
_ => bail!(
"You have {} cloud agents and none is this directory's. Name one:\n{}",
candidates.len(),
describe(&candidates)
),
}
}
fn match_selector(candidates: Vec<Agent>, selector: &str) -> Result<Agent> {
if let Some(agent) = candidates.iter().find(|a| a.id == selector) {
return Ok(agent.clone());
}
let mut by_name: Vec<Agent> = candidates
.into_iter()
.filter(|a| a.name == selector)
.collect();
match by_name.len() {
0 => bail!("No cloud agent named {selector:?}. `railway ca list` shows yours."),
1 => Ok(by_name.remove(0)),
_ => bail!(
"{} cloud agents are named {selector:?}. Use an id:\n{}",
by_name.len(),
describe(&by_name)
),
}
}
fn describe(agents: &[Agent]) -> String {
agents
.iter()
.map(|a| format!(" {} ({}) — {}", a.name, a.status.label(), a.id))
.collect::<Vec<_>>()
.join("\n")
}
pub fn remember(configs: &mut Configs, agent: &Agent) -> Result<()> {
configs.set_code_agent(&agent.environment_id, &agent.id);
configs.write()
}
pub fn forget(configs: &mut Configs, environment_id: &str) -> Result<()> {
configs.remove_code_agent(environment_id);
configs.write()
}
pub fn humanize_age(created_at: DateTime<Utc>) -> String {
let seconds = Utc::now().signed_duration_since(created_at).num_seconds();
if seconds < 0 {
return "just now".into();
}
match seconds {
s if s < 60 => format!("{s}s"),
s if s < 3600 => format!("{}m", s / 60),
s if s < 86_400 => format!("{}h", s / 3600),
s => format!("{}d", s / 86_400),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testkit::MockBackboard;
use serde_json::json;
#[tokio::test]
async fn a_missing_remembered_target_never_redirects_or_permits_creation() {
for scoped in [None, Some("env")] {
for has_other in [false, true] {
let server = MockBackboard::spawn();
let dir = tempfile::tempdir().unwrap();
let mut configs = server.configs(&dir);
configs.set_code_agent("env", "remembered");
let nodes = if has_other {
json!([{
"id": "other", "name": "other-agent", "status": "RUNNING",
"projectId": "project", "environmentId": "env",
"createdAt": "2026-09-10T00:00:00Z"
}])
} else {
json!([])
};
server.stub("MyCloudAgents", json!({"myCloudAgents": nodes}));
server.stub("CloudAgents", json!({"cloudAgents": nodes}));
let result = resolve_or_none(&configs, &reqwest::Client::new(), None, scoped).await;
assert!(
result.is_err(),
"missing identity must not select another VM or allow creation"
);
assert_eq!(configs.get_code_agent("env").as_deref(), Some("remembered"));
if has_other {
let (agent, _) =
resolve_or_none(&configs, &reqwest::Client::new(), Some("other"), scoped)
.await
.unwrap()
.unwrap();
assert_eq!(
agent.id, "other",
"an explicit target can override a missing pointer"
);
}
}
}
}
#[tokio::test]
async fn remembered_targets_outside_the_requested_scope_do_not_block_selection() {
let server = MockBackboard::spawn();
let dir = tempfile::tempdir().unwrap();
let mut configs = server.configs(&dir);
configs.set_code_agent("other-env", "old-target");
server.stub(
"CloudAgents",
json!({"cloudAgents": [{
"id": "in-scope", "name": "my-agent", "status": "RUNNING",
"projectId": "project", "environmentId": "env",
"createdAt": "2026-09-10T00:00:00Z"
}]}),
);
let (agent, _) = resolve_or_none(&configs, &reqwest::Client::new(), None, Some("env"))
.await
.unwrap()
.unwrap();
assert_eq!(agent.id, "in-scope");
}
#[tokio::test]
async fn failed_observations_remain_candidates_and_keep_the_remembered_target() {
for status in ["FAILED", "CRASHED", "DELETING", "FUTURE_STATE"] {
let server = MockBackboard::spawn();
let dir = tempfile::tempdir().unwrap();
let mut configs = server.configs(&dir);
let node = json!({
"id": "existing", "name": "my-agent", "status": status,
"projectId": "project", "environmentId": "env",
"createdAt": "2026-09-10T00:00:00Z"
});
server.stub("MyCloudAgents", json!({"myCloudAgents": [node.clone()]}));
let client = reqwest::Client::new();
let (agent, how) = resolve_or_none(&configs, &client, None, None)
.await
.unwrap()
.expect("an observation must not erase a VM");
assert_eq!(agent.id, "existing");
assert!(matches!(how, Resolution::Sole));
configs.set_code_agent("env", "existing");
let mut other = node.clone();
other["id"] = json!("other");
other["status"] = json!("RUNNING");
server.stub("CloudAgents", json!({"cloudAgents": [node, other]}));
let (agent, how) = resolve_or_none(&configs, &client, None, Some("env"))
.await
.unwrap()
.unwrap();
assert_eq!(
agent.id, "existing",
"{status} must not redirect to the running VM"
);
assert!(matches!(how, Resolution::Remembered));
assert_eq!(
server.variables_for("CloudAgents"),
vec![json!({"environmentId": "env", "mine": true})]
);
}
}
#[tokio::test]
async fn only_an_empty_inventory_permits_automatic_creation() {
let server = MockBackboard::spawn();
let dir = tempfile::tempdir().unwrap();
let configs = server.configs(&dir);
let client = reqwest::Client::new();
server.stub("MyCloudAgents", json!({"myCloudAgents": []}));
assert!(
resolve_or_none(&configs, &client, None, None)
.await
.unwrap()
.is_none()
);
server.stub_graphql_error("CloudAgents", "temporarily unavailable");
assert!(
resolve_or_none(&configs, &client, None, Some("env"))
.await
.is_err()
);
assert!(
resolve_or_none(&configs, &client, Some("missing"), None)
.await
.is_err()
);
}
fn agent(id: &str, name: &str, status: Status) -> Agent {
Agent {
id: id.into(),
name: name.into(),
status,
project_id: "project".into(),
environment_id: "env".into(),
created_at: Utc::now(),
}
}
#[test]
fn selector_matches_id_before_name() {
let candidates = vec![
agent("abc", "first", Status::Running),
agent("def", "abc", Status::Running),
];
let found = match_selector(candidates, "abc").unwrap();
assert_eq!(found.name, "first");
}
#[test]
fn selector_matches_name() {
let candidates = vec![agent("abc", "sunny-cloud", Status::Sleeping)];
assert_eq!(match_selector(candidates, "sunny-cloud").unwrap().id, "abc");
}
#[test]
fn duplicate_names_are_ambiguous_rather_than_guessed() {
let candidates = vec![
agent("abc", "dev", Status::Running),
agent("def", "dev", Status::Running),
];
let err = match_selector(candidates, "dev").unwrap_err().to_string();
assert!(err.contains("abc"), "error should list ids: {err}");
assert!(err.contains("def"), "error should list ids: {err}");
}
#[test]
fn unknown_selector_points_at_list() {
let err = match_selector(vec![agent("abc", "dev", Status::Running)], "nope")
.unwrap_err()
.to_string();
assert!(err.contains("railway ca list"), "{err}");
}
#[test]
fn terminal_states_are_not_live() {
assert!(Status::Running.is_live());
assert!(Status::Sleeping.is_live());
assert!(Status::Starting.is_live());
assert!(!Status::Crashed.is_live());
assert!(!Status::Failed.is_live());
assert!(!Status::Deleting.is_live());
assert!(!Status::Unknown("wat".into()).is_live());
}
#[test]
fn shell_is_seeded_when_absent() {
let vars = with_default_variables(None).unwrap();
assert_eq!(vars["SHELL"], "/bin/bash");
let vars = with_default_variables(Some(serde_json::json!({ "FOO": "bar" }))).unwrap();
assert_eq!(vars["SHELL"], "/bin/bash");
assert_eq!(vars["FOO"], "bar");
}
#[test]
fn a_callers_shell_wins_over_the_default() {
let vars =
with_default_variables(Some(serde_json::json!({ "SHELL": "/bin/zsh" }))).unwrap();
assert_eq!(vars["SHELL"], "/bin/zsh");
}
#[test]
fn age_uses_one_unit() {
assert_eq!(
humanize_age(Utc::now() - chrono::Duration::seconds(30)),
"30s"
);
assert_eq!(
humanize_age(Utc::now() - chrono::Duration::minutes(5)),
"5m"
);
assert_eq!(humanize_age(Utc::now() - chrono::Duration::hours(3)), "3h");
assert_eq!(humanize_age(Utc::now() - chrono::Duration::days(9)), "9d");
}
}