pub mod app;
pub mod session;
pub mod theme;
mod ui;
pub mod wizard;
use std::io::{Write, stdout};
use std::panic;
use anyhow::Result;
use crossterm::cursor::{Hide, Show};
use crossterm::event::{
DisableMouseCapture, EnableMouseCapture, Event, EventStream, KeyEventKind, KeyModifiers,
KeyboardEnhancementFlags, MouseButton, MouseEventKind, PopKeyboardEnhancementFlags,
PushKeyboardEnhancementFlags,
};
use crossterm::execute;
use crossterm::terminal::{
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
};
use futures_util::StreamExt;
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use tokio::sync::mpsc;
use app::{
Agent, AgentOp, ConsoleSession, EnvNode, Load, LoadSessions, ProjectNode, WorkspaceNode,
};
pub use app::{App, Effect, LaunchRequest, Target};
use crate::client::post_graphql;
use crate::commands::code::{self, LaunchArgs, Prepared, Progress};
use crate::config::Configs;
use crate::gql::{mutations, queries};
async fn create_default_project(
client: &reqwest::Client,
backboard: &str,
) -> Result<wizard::ProjectOption> {
use crate::gql::mutations;
let configs = Configs::new()?;
let workspaces = crate::workspace::workspaces_with_client(client, &configs).await?;
let workspace = workspaces
.first()
.map(|ws| ws.id().to_string())
.ok_or_else(|| anyhow::anyhow!("no workspace to create a project in"))?;
let created = post_graphql::<mutations::ProjectCreate, _>(
client,
backboard.to_string(),
mutations::project_create::Variables {
name: Some("Cloud Agents".to_string()),
description: Some("Home for Railway cloud agents".to_string()),
workspace_id: Some(workspace),
},
)
.await?
.project_create;
let environment = created
.environments
.edges
.first()
.ok_or_else(|| anyhow::anyhow!("the new project has no environment"))?;
Ok(wizard::ProjectOption {
project_id: created.id,
project_name: created.name,
environment_id: environment.node.id.clone(),
environment_name: environment.node.name.clone(),
})
}
fn save_setup(app: &App, outcome: &wizard::Outcome) -> Result<()> {
use crate::commands::cloud_agent::prefs::{AgentPrefs, DefaultProject, SkillsPrefs};
let home = dirs::home_dir().ok_or_else(|| anyhow::anyhow!("no home directory"))?;
let prefs = AgentPrefs {
version: crate::commands::cloud_agent::prefs::CURRENT_VERSION,
agent: Some(outcome.agent.clone()),
skills: SkillsPrefs {
enabled: outcome.skills,
source: outcome.skills_source.clone(),
exclude: Vec::new(),
},
default_project: outcome.project.as_ref().map(|p| DefaultProject {
project_id: p.project_id.clone(),
project_name: p.project_name.clone(),
environment_id: p.environment_id.clone(),
environment_name: p.environment_name.clone(),
}),
theme: Some(outcome.theme.clone()),
};
let _ = app;
prefs.save_in(&home)
}
fn elide(text: &str, width: usize) -> String {
let chars: Vec<char> = text.chars().collect();
if chars.len() <= width {
return text.to_string();
}
chars[..width.saturating_sub(1)].iter().collect::<String>() + "…"
}
fn save_default_project(target: &Target) -> Result<()> {
use crate::commands::cloud_agent::prefs::{AgentPrefs, DefaultProject};
let home = dirs::home_dir().ok_or_else(|| anyhow::anyhow!("no home directory"))?;
let mut prefs = AgentPrefs::load_in(&home).unwrap_or_default();
prefs.default_project = Some(DefaultProject {
project_id: target.project_id.clone(),
project_name: target.project_name.clone(),
environment_id: target.environment_id.clone(),
environment_name: target.environment_name.clone(),
});
prefs.save_in(&home)
}
fn ssh_command_for(environment_id: &str, agent_id: &str, session_name: &str) -> String {
let (host, port) = Configs::get_ssh_relay();
let port = match port {
Some(port) if port != 22 => format!("-p {port} "),
_ => String::new(),
};
format!(
"ssh {port}-o SetEnv=RAILWAY_DURABLE_SESSION_NAME={session_name} agent:{environment_id}:{agent_id}@{host}"
)
}
const SPINNER_TICK: std::time::Duration = std::time::Duration::from_millis(110);
pub enum Outcome {
NeedsCredential(LaunchRequest),
FullScreen(FullScreenRequest),
Quit,
}
pub struct FullScreenRequest {
pub ssh_target: String,
pub identity: Option<std::path::PathBuf>,
pub relay_opts: Vec<String>,
pub session_name: String,
pub agent_name: String,
}
enum Message {
AgentsLoaded {
path: (usize, usize, usize),
result: Result<Vec<Agent>, String>,
},
SessionsLoaded {
path: (usize, usize, usize, usize),
result: Result<Vec<ConsoleSession>, String>,
},
LaunchStep(String),
LaunchReady(Box<Prepared>, Box<LaunchRequest>),
LaunchFailed(String),
AgentOpDone {
agent_id: String,
environment_id: String,
op: AgentOp,
error: Option<String>,
},
ReattachReady {
agent_id: String,
agent_name: String,
session_name: String,
info: Box<code::ConnectInfo>,
},
SessionKilled {
agent_id: String,
session_name: String,
error: Option<String>,
},
ProjectCreated(Result<wizard::ProjectOption, String>),
RefreshAgentSessions(String),
SessionOutput,
}
struct ChannelProgress(mpsc::UnboundedSender<Message>);
impl Progress for ChannelProgress {
fn step(&self, text: &str) {
let _ = self.0.send(Message::LaunchStep(text.to_string()));
}
fn note(&self, text: &str) {
let _ = self.0.send(Message::LaunchStep(text.to_string()));
}
fn finish(&self) {}
}
pub async fn load_tree(client: &reqwest::Client, configs: &Configs) -> Result<Vec<WorkspaceNode>> {
let workspaces = crate::workspace::workspaces_with_client(client, configs).await?;
Ok(workspaces
.into_iter()
.map(|ws| WorkspaceNode {
name: ws.name().to_string(),
expanded: false,
projects: ws
.projects()
.into_iter()
.filter(|p| p.deleted_at().is_none())
.map(|p| ProjectNode {
id: p.id().to_string(),
name: p.name().to_string(),
expanded: false,
envs: p
.environments()
.into_iter()
.filter(|e| e.can_access)
.map(|e| EnvNode {
id: e.id,
name: e.name,
expanded: false,
agents: Load::NotLoaded,
})
.collect(),
})
.filter(|p| !p.envs.is_empty())
.collect(),
})
.filter(|ws| !ws.projects.is_empty())
.collect())
}
async fn fetch_agents(
client: &reqwest::Client,
backboard: &str,
environment_id: &str,
) -> Result<Vec<Agent>> {
let res = post_graphql::<queries::CloudAgents, _>(
client,
backboard,
queries::cloud_agents::Variables {
environment_id: environment_id.to_owned(),
mine: Some(true),
},
)
.await?;
Ok(res
.cloud_agents
.into_iter()
.map(|a| Agent {
id: a.id,
name: a.name,
status: format!("{:?}", a.status).to_lowercase(),
sessions: LoadSessions::NotLoaded,
expanded: false,
})
.collect())
}
async fn fetch_sessions(
client: &reqwest::Client,
backboard: &str,
cloud_agent_id: &str,
) -> Result<Vec<ConsoleSession>> {
let res = post_graphql::<queries::CloudAgentConsoleSessions, _>(
client,
backboard,
queries::cloud_agent_console_sessions::Variables {
cloud_agent_id: cloud_agent_id.to_owned(),
},
)
.await?;
Ok(res
.cloud_agent_console_sessions
.map(|conn| {
conn.edges
.into_iter()
.map(|edge| ConsoleSession {
name: edge.node.name,
kind: format!("{:?}", edge.node.kind),
command: Some(edge.node.command),
running: edge.node.run_state.running,
attached: edge.node.attached,
})
.collect()
})
.unwrap_or_default())
}
pub async fn run(
app: &mut App,
client: reqwest::Client,
backboard: String,
pending: Option<LaunchRequest>,
) -> Result<Outcome> {
let original_hook = panic::take_hook();
panic::set_hook(Box::new(move |info| {
restore_terminal();
original_hook(info);
}));
let mut terminal = setup_terminal()?;
let _cleanup = scopeguard::guard((), |_| restore_terminal());
let mut events = EventStream::new();
let (tx, mut rx) = mpsc::unbounded_channel::<Message>();
if let Some(req) = pending {
start_launch(app, req, &tx);
}
let sweep = app.unloaded_environments();
if !sweep.is_empty() {
spawn_sweep(sweep, &tx, &client, &backboard);
}
loop {
let mut rects = app.panes;
let mut copied: Option<String> = None;
terminal.draw(|f| {
let (r, text) = ui::render_with_layout(app, f);
rects = r;
copied = text;
})?;
app.panes = rects;
if app.pending_copy.take().is_some() {
finish_copy(app, copied);
}
sync_session_size(app, &terminal);
let effect = tokio::select! {
Some(message) = rx.recv() => handle_message(app, message, &tx, &client, &backboard),
_ = tokio::time::sleep(SPINNER_TICK), if app.loading.active
|| app.wizard.as_ref().is_some_and(|w| w.busy.is_some()) => {
app.tick();
None
}
_ = tokio::time::sleep(app.toast_remaining()), if app.toast.is_some() => {
app.expire_toast();
None
}
_ = tokio::time::sleep(app::WATCH_TICK), if app.watching_agents() => {
app.watch_tick()
}
event = events.next() => match event {
Some(Ok(Event::Key(key))) if key.kind == KeyEventKind::Press => app.on_key(key),
Some(Ok(Event::Mouse(mouse))) => {
let action = match mouse.kind {
MouseEventKind::Down(MouseButton::Left) => Some(app::MouseAction::Down),
MouseEventKind::Drag(MouseButton::Left) => Some(app::MouseAction::Drag),
MouseEventKind::Up(MouseButton::Left) => Some(app::MouseAction::Up),
MouseEventKind::ScrollUp => Some(app::MouseAction::ScrollUp),
MouseEventKind::ScrollDown => Some(app::MouseAction::ScrollDown),
_ => None,
};
let shift = mouse.modifiers.contains(KeyModifiers::SHIFT);
action.and_then(|action| {
app.on_mouse_shifted(action, mouse.column, mouse.row, shift)
})
}
Some(Ok(Event::Resize(..))) => { terminal.clear()?; None }
None => Some(Effect::Quit),
_ => None,
},
};
match effect {
None => {}
Some(Effect::Quit) => {
while !app.sessions.is_empty() {
close_and_sleep(app, 0, &client, &backboard).await;
}
return Ok(Outcome::Quit);
}
Some(Effect::FullScreen {
agent_id,
session_name,
agent_name,
}) => {
let Some(index) = app.sessions.iter().position(|s| s.agent_id == agent_id) else {
continue;
};
let Some(session) = app.detach_session(index) else {
continue;
};
return Ok(Outcome::FullScreen(FullScreenRequest {
ssh_target: session.ssh_target.clone(),
identity: session.identity.clone(),
relay_opts: session.relay_opts.clone(),
session_name,
agent_name,
}));
}
Some(Effect::Reattach {
agent_id,
agent_name,
environment_id,
session_name,
}) => {
let tx = tx.clone();
tokio::spawn(async move {
let message = match code::connect_info(&environment_id, &agent_id).await {
Ok(info) => Message::ReattachReady {
agent_id,
agent_name,
session_name,
info: Box::new(info),
},
Err(err) => Message::LaunchFailed(format!("{err:#}")),
};
let _ = tx.send(message);
});
}
Some(Effect::CreateDefaultProject) => {
let tx = tx.clone();
let client = client.clone();
let backboard = backboard.clone();
tokio::spawn(async move {
let result = create_default_project(&client, &backboard)
.await
.map_err(|e| format!("{e:#}"));
let _ = tx.send(Message::ProjectCreated(result));
});
}
Some(Effect::SaveSetup(outcome)) => {
app.status = match save_setup(app, &outcome) {
Ok(()) => "Saved — Setup again to change it".into(),
Err(err) => format!("Couldn't save your setup: {err:#}"),
};
app.set_harness(Some(&outcome.agent));
app.set_theme(Some(&outcome.theme));
if let Some(project) = outcome.project {
app.default_project = Some(project.project_id.clone());
app.target = Some(Target {
project_id: project.project_id,
project_name: project.project_name,
environment_id: project.environment_id,
environment_name: project.environment_name,
});
}
}
Some(Effect::OpenUrl(url)) => {
match ::open::that_detached(&url) {
Ok(()) => app.toast(format!("Opened {}", elide(&url, 48))),
Err(err) => app.toast_error(format!("Couldn't open it: {err}")),
}
}
Some(Effect::SaveDefaultProject(target)) => {
app.status = match save_default_project(&target) {
Ok(()) => format!("Default project is now {}", target.label()),
Err(err) => format!("Couldn't save your default project: {err:#}"),
};
}
Some(Effect::CopySsh {
agent_id,
environment_id,
session_name,
}) => {
let command = ssh_command_for(&environment_id, &agent_id, &session_name);
match arboard::Clipboard::new().and_then(|mut c| c.set_text(command)) {
Ok(()) => app.toast("Copied the ssh command"),
Err(err) => app.toast_error(format!("Couldn't copy: {err}")),
}
}
Some(Effect::KillSession {
agent_id,
environment_id,
session_name,
}) => {
if let Some(index) = app
.sessions
.iter()
.position(|s| s.durable_name == session_name)
{
close_session(app, index, &client, &backboard).await;
}
let tx = tx.clone();
tokio::spawn(async move {
let error = code::kill_session(&environment_id, &agent_id, &session_name)
.await
.err()
.map(|e| format!("{e:#}"));
let _ = tx.send(Message::SessionKilled {
agent_id,
session_name,
error,
});
});
}
Some(Effect::CloseSession { index }) => {
close_session(app, index, &client, &backboard).await
}
Some(Effect::Agent {
op,
agent_id,
environment_id,
}) => {
if op == AgentOp::Delete
&& let Some(index) = app.sessions.iter().position(|s| s.agent_id == agent_id)
{
close_session(app, index, &client, &backboard).await;
}
let tx = tx.clone();
let client = client.clone();
let backboard = backboard.clone();
tokio::spawn(async move {
let error = run_agent_op(&client, &backboard, op, &agent_id, &environment_id)
.await
.err()
.map(|e| format!("{e:#}"));
let _ = tx.send(Message::AgentOpDone {
agent_id,
environment_id,
op,
error,
});
});
}
Some(Effect::Launch(req)) => {
if req.harness == "claude" && !code::claude_credential_cached() {
return Ok(Outcome::NeedsCredential(req));
}
start_launch(app, req, &tx);
}
Some(Effect::LoadSessions { agent_id, path }) => {
spawn_session_fetch(agent_id, path, &tx, &client, &backboard);
}
Some(Effect::LoadAgents {
environment_id,
path,
}) => {
let tx = tx.clone();
let client = client.clone();
let backboard = backboard.clone();
tokio::spawn(async move {
let result = fetch_agents(&client, &backboard, &environment_id)
.await
.map_err(|e| e.to_string());
let _ = tx.send(Message::AgentsLoaded { path, result });
});
}
}
}
}
fn handle_message(
app: &mut App,
message: Message,
tx: &mpsc::UnboundedSender<Message>,
client: &reqwest::Client,
backboard: &str,
) -> Option<Effect> {
match message {
Message::AgentsLoaded { path, result } => {
app.agents_loaded(path, result);
for effect in app.sessions_to_prefetch() {
if let Effect::LoadSessions { agent_id, path } = effect {
spawn_session_fetch(agent_id, path, tx, client, backboard);
}
}
app.expand_pending()
}
Message::SessionsLoaded { path, result } => {
app.sessions_loaded(path, result);
None
}
Message::LaunchStep(text) => {
app.loading_step(text);
None
}
Message::LaunchFailed(err) => {
app.launch_failed(err);
None
}
Message::AgentOpDone {
agent_id,
environment_id,
op,
error,
} => {
app.agent_op_finished(&agent_id, &environment_id, op, error);
app.reveal_environment(&environment_id)
}
Message::LaunchReady(prepared, req) => open_session(app, *prepared, *req, tx),
Message::ReattachReady {
agent_id,
agent_name,
session_name,
info,
} => {
let notify_tx = tx.clone();
match session::Session::spawn(
agent_id.clone(),
agent_name,
"session".to_string(),
&info.ssh_target,
info.identity.as_deref(),
&info.relay_opts,
"",
true,
&session_name,
24,
80,
move || {
let _ = notify_tx.send(Message::SessionOutput);
},
) {
Ok(session) => {
app.attach_session(session, agent_id);
None
}
Err(err) => {
app.launch_failed(format!("couldn't reattach: {err}"));
None
}
}
}
Message::ProjectCreated(result) => {
if let Some(w) = app.wizard.as_mut() {
w.project_created(result);
}
None
}
Message::SessionKilled {
agent_id,
session_name,
error,
} => {
app.session_killed(&session_name, error);
app.refresh_agent_sessions(&agent_id)
}
Message::RefreshAgentSessions(agent_id) => app.refresh_agent_sessions(&agent_id),
Message::SessionOutput => None,
}
}
fn open_session(
app: &mut App,
prepared: Prepared,
req: LaunchRequest,
tx: &mpsc::UnboundedSender<Message>,
) -> Option<Effect> {
if !req.wants_new_session()
&& req.session_name.is_none()
&& app.activate_session(&prepared.agent_id)
{
app.screen = app::Screen::Manage;
app.status = "Switched to the open session".into();
return app.reveal_environment(&prepared.environment_id);
}
let durable_session = req
.session_name
.clone()
.unwrap_or_else(|| session::durable_name(prepared.harness));
let notify_tx = tx.clone();
let (rows, cols) = (24u16, 80u16);
match session::Session::spawn(
prepared.agent_id.clone(),
prepared.agent_name.clone(),
prepared.harness.to_string(),
&prepared.ssh_target,
prepared.identity.as_deref(),
&prepared.relay_opts,
&prepared.remote_cmd,
req.session_name.is_some(),
&durable_session,
rows,
cols,
move || {
let _ = notify_tx.send(Message::SessionOutput);
},
) {
Ok(session) => {
app.attach_session(session, prepared.agent_id.clone());
schedule_session_refresh(prepared.agent_id.clone(), tx);
app.expand_agent_after_load(prepared.agent_id.clone());
app.reveal_environment(&prepared.environment_id)
}
Err(err) => {
app.launch_failed(format!("couldn't open the session: {err}"));
None
}
}
}
const SESSION_SETTLE: [u64; 2] = [900, 2600];
const SWEEP_CONCURRENCY: usize = 5;
fn spawn_sweep(
effects: Vec<Effect>,
tx: &mpsc::UnboundedSender<Message>,
client: &reqwest::Client,
backboard: &str,
) {
let tx = tx.clone();
let client = client.clone();
let backboard = backboard.to_string();
tokio::spawn(async move {
let permits = std::sync::Arc::new(tokio::sync::Semaphore::new(SWEEP_CONCURRENCY));
for effect in effects {
let Effect::LoadAgents {
environment_id,
path,
} = effect
else {
continue;
};
let Ok(permit) = permits.clone().acquire_owned().await else {
return;
};
let tx = tx.clone();
let client = client.clone();
let backboard = backboard.clone();
tokio::spawn(async move {
let result = fetch_agents(&client, &backboard, &environment_id)
.await
.map_err(|e| e.to_string());
let _ = tx.send(Message::AgentsLoaded { path, result });
drop(permit);
});
}
});
}
fn spawn_session_fetch(
agent_id: String,
path: (usize, usize, usize, usize),
tx: &mpsc::UnboundedSender<Message>,
client: &reqwest::Client,
backboard: &str,
) {
let tx = tx.clone();
let client = client.clone();
let backboard = backboard.to_string();
tokio::spawn(async move {
let result = fetch_sessions(&client, &backboard, &agent_id)
.await
.map_err(|e| e.to_string());
let _ = tx.send(Message::SessionsLoaded { path, result });
});
}
fn schedule_session_refresh(agent_id: String, tx: &mpsc::UnboundedSender<Message>) {
let tx = tx.clone();
tokio::spawn(async move {
for delay in SESSION_SETTLE {
tokio::time::sleep(std::time::Duration::from_millis(delay)).await;
if tx
.send(Message::RefreshAgentSessions(agent_id.clone()))
.is_err()
{
return;
}
}
});
}
fn launch_args_for(req: &LaunchRequest) -> LaunchArgs {
LaunchArgs::for_target(
req.project_id.clone(),
req.environment_id.clone(),
&req.harness,
req.force_new,
req.prompt.clone(),
req.agent_id.clone(),
)
}
fn start_launch(app: &mut App, req: LaunchRequest, tx: &mpsc::UnboundedSender<Message>) {
app.start_loading(&req);
let tx = tx.clone();
tokio::spawn(async move {
let req = req;
let args = launch_args_for(&req);
let progress = ChannelProgress(tx.clone());
let message = match code::prepare(&args, &progress).await {
Ok(prepared) => Message::LaunchReady(Box::new(prepared), Box::new(req)),
Err(err) => Message::LaunchFailed(format!("{err:#}")),
};
let _ = tx.send(message);
});
}
async fn run_agent_op(
client: &reqwest::Client,
backboard: &str,
op: AgentOp,
agent_id: &str,
environment_id: &str,
) -> Result<()> {
let backboard = backboard.to_string();
match op {
AgentOp::Sleep => {
post_graphql::<mutations::CloudAgentSleep, _>(
client,
backboard,
mutations::cloud_agent_sleep::Variables {
id: agent_id.to_string(),
},
)
.await?;
}
AgentOp::Wake => {
post_graphql::<mutations::CloudAgentWake, _>(
client,
backboard,
mutations::cloud_agent_wake::Variables {
id: agent_id.to_string(),
},
)
.await?;
}
AgentOp::Delete => {
post_graphql::<mutations::CloudAgentDelete, _>(
client,
backboard,
mutations::cloud_agent_delete::Variables {
id: agent_id.to_string(),
},
)
.await?;
}
}
let _ = environment_id;
Ok(())
}
async fn close_session(app: &mut App, index: usize, _client: &reqwest::Client, _backboard: &str) {
if let Some(mut session) = app.take_session(index) {
session.detach();
}
}
async fn close_and_sleep(app: &mut App, index: usize, client: &reqwest::Client, backboard: &str) {
let Some(mut session) = app.take_session(index) else {
return;
};
let agent_id = session.agent_id.clone();
session.detach();
drop(session);
let _ = post_graphql::<mutations::CloudAgentSleep, _>(
client,
backboard.to_string(),
mutations::cloud_agent_sleep::Variables { id: agent_id },
)
.await;
}
fn sync_session_size(app: &mut App, terminal: &Terminal<CrosstermBackend<std::io::Stdout>>) {
let Some((rows, cols)) = ui::session_pane_size(terminal.size().ok(), app.maximized) else {
return;
};
for session in app.sessions.iter_mut() {
session.resize(rows, cols);
}
}
fn finish_copy(app: &mut App, text: Option<String>) {
app.selection = None;
let Some(text) = text else {
return;
};
let lines = text.lines().count();
match arboard::Clipboard::new().and_then(|mut c| c.set_text(text)) {
Ok(()) => app.toast(format!(
"Copied {lines} line{}",
if lines == 1 { "" } else { "s" }
)),
Err(err) => app.toast_error(format!("Couldn't copy: {err}")),
}
}
fn setup_terminal() -> Result<Terminal<CrosstermBackend<std::io::Stdout>>> {
enable_raw_mode()?;
execute!(stdout(), EnterAlternateScreen, EnableMouseCapture, Hide)?;
if matches!(
crossterm::terminal::supports_keyboard_enhancement(),
Ok(true)
) {
let _ = execute!(
stdout(),
PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES)
);
}
let backend = CrosstermBackend::new(stdout());
let mut terminal = Terminal::new(backend)?;
terminal.clear()?;
Ok(terminal)
}
fn restore_terminal() {
let _ = execute!(stdout(), PopKeyboardEnhancementFlags);
let _ = execute!(stdout(), DisableMouseCapture, LeaveAlternateScreen, Show);
let _ = execute!(stdout(), DisableMouseCapture);
let _ = disable_raw_mode();
let _ = stdout().flush();
}
#[cfg(test)]
mod tests {
use super::*;
fn request() -> LaunchRequest {
LaunchRequest {
project_id: "proj_1".into(),
environment_id: "env_prod".into(),
agent_id: None,
session_name: None,
force_new: false,
new_session: false,
harness: "claude".into(),
prompt: None,
label: "devtools/production".into(),
}
}
#[test]
fn a_new_session_request_pins_its_agent_and_creates_nothing() {
let args = launch_args_for(&LaunchRequest {
agent_id: Some("ca_1".into()),
new_session: true,
..request()
});
assert_eq!(args.agent_id.as_deref(), Some("ca_1"));
assert!(!args.new, "must not ask the pipeline to create an agent");
assert_eq!(args.environment.as_deref(), Some("env_prod"));
assert_eq!(args.project.as_deref(), Some("proj_1"));
}
#[test]
fn a_new_agent_request_creates_and_pins_nothing() {
let args = launch_args_for(&LaunchRequest {
force_new: true,
..request()
});
assert!(args.new);
assert_eq!(args.agent_id, None);
}
#[test]
fn the_copied_ssh_command_names_the_session_and_the_relay() {
let command = ssh_command_for("env_1", "ca_1", "claude-3s9r89");
assert!(command.starts_with("ssh "), "{command}");
assert!(
command.contains("-o SetEnv=RAILWAY_DURABLE_SESSION_NAME=claude-3s9r89"),
"{command}"
);
assert!(command.contains("agent:env_1:ca_1@"), "{command}");
assert!(!command.contains(" agent:env_1:ca_1 "), "{command}");
}
#[test]
fn a_prompt_request_carries_its_task_and_agent() {
let args = launch_args_for(&LaunchRequest {
agent_id: Some("ca_9".into()),
prompt: Some("fix the tests".into()),
..request()
});
assert_eq!(args.initial_prompt.as_deref(), Some("fix the tests"));
assert_eq!(args.agent_id.as_deref(), Some("ca_9"));
assert!(!args.new);
}
}