use crate::railway::{RailwayClient, RailwayError};
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug)]
pub struct CloneConfig {
pub docker_image: Option<String>,
pub source_repo: Option<String>,
pub github_token: Option<String>,
pub rpc_url: String,
pub self_url: String,
pub max_children: u32,
pub clone_cpu_millicores: u32,
pub clone_memory_mb: u32,
pub child_env_vars: std::collections::HashMap<String, String>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct CloneResult {
pub instance_id: String,
pub url: String,
pub railway_service_id: String,
pub deployment_id: String,
pub branch: Option<String>,
pub volume_id: Option<String>,
pub designation: String,
pub clone_repo: Option<String>,
}
#[derive(Debug, thiserror::Error)]
pub enum CloneError {
#[error("Railway API error: {0}")]
Railway(#[from] RailwayError),
#[error("clone limit reached: {current}/{max} children")]
LimitReached { current: u32, max: u32 },
#[error("clone error: {0}")]
Other(String),
}
pub struct CloneOrchestrator {
railway: RailwayClient,
config: CloneConfig,
}
impl CloneOrchestrator {
pub fn new(railway: RailwayClient, config: CloneConfig) -> Self {
Self { railway, config }
}
pub async fn spawn_clone(
&self,
instance_id: &str,
parent_address: &str,
) -> Result<CloneResult, CloneError> {
self.spawn_clone_with_extra_vars(
instance_id,
parent_address,
&std::collections::HashMap::new(),
)
.await
}
pub async fn spawn_clone_with_extra_vars(
&self,
instance_id: &str,
parent_address: &str,
extra_vars: &std::collections::HashMap<String, String>,
) -> Result<CloneResult, CloneError> {
let designation = extra_vars
.get("DRONE_DESIGNATION")
.cloned()
.unwrap_or_else(|| format!("drone-{}", &instance_id[..8]));
let service_name = designation.clone();
tracing::info!(
instance_id = %instance_id,
service_name = %service_name,
"Spawning clone"
);
let service_id = self.railway.create_service(&service_name).await?;
tracing::info!(service_id = %service_id, "Railway service created");
match self
.spawn_clone_inner(
&service_id,
instance_id,
&designation,
parent_address,
extra_vars,
)
.await
{
Ok(result) => Ok(result),
Err(e) => {
tracing::error!(
service_id = %service_id,
error = %e,
"Clone failed after service creation, cleaning up"
);
if let Err(cleanup_err) = self.railway.delete_service(&service_id).await {
tracing::error!(
service_id = %service_id,
error = %cleanup_err,
"Cleanup of Railway service failed"
);
} else {
tracing::info!(service_id = %service_id, "Railway service cleaned up");
}
let branch = format!("clone/{}", &instance_id[..8]);
if let (Some(ref repo), Some(ref token)) =
(&self.config.source_repo, &self.config.github_token)
{
if let Err(branch_err) = delete_github_branch(token, repo, &branch).await {
tracing::warn!(
branch = %branch,
error = %branch_err,
"Cleanup of GitHub branch failed (best-effort)"
);
}
}
Err(e)
}
}
}
async fn spawn_clone_inner(
&self,
service_id: &str,
instance_id: &str,
designation: &str,
parent_address: &str,
extra_vars: &std::collections::HashMap<String, String>,
) -> Result<CloneResult, CloneError> {
let env_id = self.railway.get_default_environment().await?;
let clone_repo: Option<String> = None;
let deploy_repo = self.config.source_repo.as_deref();
let use_source = deploy_repo.is_some();
let branch_name = if use_source {
Some("main".to_string())
} else {
None
};
let mut env_map = serde_json::Map::new();
env_map.insert("AUTO_BOOTSTRAP".into(), "true".into());
env_map.insert("INSTANCE_ID".into(), instance_id.into());
env_map.insert("PARENT_URL".into(), self.config.self_url.clone().into());
env_map.insert("PARENT_ADDRESS".into(), parent_address.into());
env_map.insert("IDENTITY_PATH".into(), "/data/identity.json".into());
env_map.insert("DB_PATH".into(), "/data/gateway.db".into());
env_map.insert("NONCE_DB_PATH".into(), "/data/x402-nonces.db".into());
env_map.insert("RPC_URL".into(), self.config.rpc_url.clone().into());
env_map.insert("SPA_DIR".into(), "/app/spa".into());
env_map.insert("PORT".into(), "4023".into());
for (key, value) in &self.config.child_env_vars {
env_map.insert(key.clone(), value.clone().into());
}
for (key, value) in extra_vars {
env_map.insert(key.clone(), value.clone().into());
}
if let Some(ref repo) = clone_repo {
env_map.insert("SOUL_FORK_REPO".into(), repo.clone().into());
if let Some(ref source) = self.config.source_repo {
env_map.insert("SOUL_UPSTREAM_REPO".into(), source.clone().into());
}
}
let env_vars = serde_json::Value::Object(env_map);
self.railway
.set_variables(service_id, &env_id, env_vars)
.await?;
tracing::info!("Environment variables configured");
if let (Some(repo), Some(ref branch)) = (deploy_repo, &branch_name) {
self.railway
.connect_repo(service_id, &env_id, repo, branch)
.await?;
tracing::info!(repo = %repo, branch = %branch, "Source repo connected");
} else if let Some(ref image) = self.config.docker_image {
self.railway.set_docker_image(service_id, image).await?;
tracing::info!(image = %image, "Docker image set");
} else {
return Err(CloneError::Other(
"no deployment source: set DOCKER_IMAGE or CLONE_SOURCE_REPO".to_string(),
));
}
let volume_id = match self.railway.add_volume(service_id, &env_id, "/data").await {
Ok(vid) => {
tracing::info!(volume_id = %vid, "Volume attached at /data");
Some(vid)
}
Err(e) => {
tracing::warn!(error = %e, "Volume attachment failed (best-effort, continuing)");
None
}
};
if self.config.clone_cpu_millicores > 0 || self.config.clone_memory_mb > 0 {
match self
.railway
.update_service_resources(
service_id,
&env_id,
self.config.clone_cpu_millicores,
self.config.clone_memory_mb,
)
.await
{
Ok(_) => tracing::info!(
cpu = self.config.clone_cpu_millicores,
memory_mb = self.config.clone_memory_mb,
"Resource limits configured"
),
Err(e) => {
tracing::warn!(error = %e, "Resource limits failed (best-effort, continuing)")
}
}
}
let url = self.railway.create_domain(service_id, &env_id).await?;
tracing::info!(url = %url, "Domain created");
let origins = format!("{},{}", url, self.config.self_url);
let origins_var = serde_json::Value::Object({
let mut m = serde_json::Map::new();
m.insert("ALLOWED_ORIGINS".into(), origins.into());
m
});
let _ = self
.railway
.set_variables(service_id, &env_id, origins_var)
.await;
let deployment_id = if branch_name.is_none() {
let id = self.railway.deploy_service(service_id, &env_id).await?;
tracing::info!(deployment_id = %id, "Deployment triggered");
id
} else {
tracing::info!("Source-based clone — deployment trigger will build automatically");
"trigger-based".to_string()
};
Ok(CloneResult {
instance_id: instance_id.to_string(),
url,
railway_service_id: service_id.to_string(),
deployment_id,
branch: branch_name,
volume_id,
designation: designation.to_string(),
clone_repo,
})
}
pub async fn redeploy_clone(&self, service_id: &str) -> Result<String, CloneError> {
let env_id = self.railway.get_default_environment().await?;
let result = self.railway.deploy_service(service_id, &env_id).await?;
Ok(result)
}
pub async fn update_and_redeploy_clone(
&self,
service_id: &str,
instance_id: &str,
) -> Result<String, CloneError> {
if let (Some(ref repo), Some(ref token)) =
(&self.config.source_repo, &self.config.github_token)
{
let branch = format!("clone/{}", &instance_id[..8]);
match update_github_branch_to_main(token, repo, &branch).await {
Ok(()) => {
tracing::info!(
instance_id = %instance_id,
branch = %branch,
"Updated clone branch to main"
);
}
Err(e) => {
tracing::warn!(
instance_id = %instance_id,
error = %e,
"Failed to update clone branch (will redeploy anyway)"
);
}
}
}
self.redeploy_clone(service_id).await
}
pub async fn delete_volume(&self, volume_id: &str) -> Result<(), CloneError> {
self.railway.delete_volume(volume_id).await?;
Ok(())
}
pub async fn delete_service(&self, service_id: &str) -> Result<(), CloneError> {
self.railway.delete_service(service_id).await?;
Ok(())
}
pub fn config(&self) -> &CloneConfig {
&self.config
}
}
pub async fn create_clone_repo(
token: &str,
source_repo: &str,
designation: &str,
) -> Result<String, CloneError> {
let http = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.redirect(reqwest::redirect::Policy::limited(5))
.build()
.map_err(|e| CloneError::Other(format!("HTTP client error: {e}")))?;
let org = source_repo
.split('/')
.next()
.ok_or_else(|| CloneError::Other("invalid source_repo format".to_string()))?;
let repo_name = designation.to_string();
let full_name = format!("{org}/{repo_name}");
let create_url = format!("https://api.github.com/orgs/{org}/repos");
let body = serde_json::json!({
"name": repo_name,
"description": format!("x402 autonomous agent: {designation}"),
"private": false,
"auto_init": false,
});
let resp = http
.post(&create_url)
.header("Authorization", format!("Bearer {token}"))
.header("Accept", "application/vnd.github+json")
.header("User-Agent", "x402-node")
.header("X-GitHub-Api-Version", "2022-11-28")
.json(&body)
.send()
.await
.map_err(|e| CloneError::Other(format!("GitHub create repo error: {e}")))?;
if !resp.status().is_success() {
let user_url = "https://api.github.com/user/repos";
let resp2 = http
.post(user_url)
.header("Authorization", format!("Bearer {token}"))
.header("Accept", "application/vnd.github+json")
.header("User-Agent", "x402-node")
.header("X-GitHub-Api-Version", "2022-11-28")
.json(&body)
.send()
.await
.map_err(|e| CloneError::Other(format!("GitHub create repo (user) error: {e}")))?;
if !resp2.status().is_success() {
let status = resp2.status();
let body = resp2.text().await.unwrap_or_default();
if status.as_u16() == 422 && body.contains("already exists") {
tracing::info!(repo = %full_name, "Clone repo already exists — reusing");
} else {
return Err(CloneError::Other(format!(
"GitHub create repo failed (HTTP {status}): {}",
body.chars().take(200).collect::<String>()
)));
}
}
}
tracing::info!(repo = %full_name, "Created clone repo");
let source_url = format!("https://x-access-token:{token}@github.com/{source_repo}.git");
let target_url = format!("https://x-access-token:{token}@github.com/{full_name}.git");
let tmp_dir = format!("/tmp/clone-mirror-{designation}");
let _ = tokio::fs::remove_dir_all(&tmp_dir).await;
let clone_result = tokio::process::Command::new("git")
.args(["clone", "--bare", &source_url, &tmp_dir])
.output()
.await
.map_err(|e| CloneError::Other(format!("git clone --bare failed: {e}")))?;
if !clone_result.status.success() {
let stderr = String::from_utf8_lossy(&clone_result.stderr);
let _ = tokio::fs::remove_dir_all(&tmp_dir).await;
return Err(CloneError::Other(format!(
"git clone --bare failed: {}",
stderr.chars().take(200).collect::<String>()
)));
}
let push_result = tokio::process::Command::new("git")
.args(["push", "--mirror", &target_url])
.current_dir(&tmp_dir)
.output()
.await
.map_err(|e| CloneError::Other(format!("git push --mirror failed: {e}")))?;
let _ = tokio::fs::remove_dir_all(&tmp_dir).await;
if !push_result.status.success() {
let stderr = String::from_utf8_lossy(&push_result.stderr);
return Err(CloneError::Other(format!(
"git push --mirror failed: {}",
stderr.chars().take(200).collect::<String>()
)));
}
tracing::info!(
source = %source_repo,
target = %full_name,
"Mirrored colony baseline into clone repo"
);
Ok(full_name)
}
async fn create_github_branch(token: &str, repo: &str, branch: &str) -> Result<(), CloneError> {
let http = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(15))
.redirect(reqwest::redirect::Policy::limited(5))
.build()
.map_err(|e| CloneError::Other(format!("HTTP client error: {e}")))?;
let ref_url = format!("https://api.github.com/repos/{repo}/git/ref/heads/main");
let ref_resp = http
.get(&ref_url)
.header("Authorization", format!("Bearer {token}"))
.header("Accept", "application/vnd.github+json")
.header("User-Agent", "x402-node")
.header("X-GitHub-Api-Version", "2022-11-28")
.send()
.await
.map_err(|e| CloneError::Other(format!("GitHub API error (get ref): {e}")))?;
if !ref_resp.status().is_success() {
let status = ref_resp.status();
let body = ref_resp.text().await.unwrap_or_default();
return Err(CloneError::Other(format!(
"GitHub GET ref failed (HTTP {status}): {body}"
)));
}
let ref_json: serde_json::Value = ref_resp
.json()
.await
.map_err(|e| CloneError::Other(format!("GitHub API parse error: {e}")))?;
let sha = ref_json["object"]["sha"]
.as_str()
.ok_or_else(|| CloneError::Other("missing SHA in GitHub ref response".to_string()))?;
let create_url = format!("https://api.github.com/repos/{repo}/git/refs");
let create_resp = http
.post(&create_url)
.header("Authorization", format!("Bearer {token}"))
.header("Accept", "application/vnd.github+json")
.header("User-Agent", "x402-node")
.header("X-GitHub-Api-Version", "2022-11-28")
.json(&serde_json::json!({
"ref": format!("refs/heads/{branch}"),
"sha": sha,
}))
.send()
.await
.map_err(|e| CloneError::Other(format!("GitHub API error (create ref): {e}")))?;
if !create_resp.status().is_success() {
let status = create_resp.status();
let body = create_resp.text().await.unwrap_or_default();
return Err(CloneError::Other(format!(
"GitHub create branch failed (HTTP {status}): {body}"
)));
}
Ok(())
}
pub async fn update_github_branch_to_main(
token: &str,
repo: &str,
branch: &str,
) -> Result<(), CloneError> {
let http = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(15))
.redirect(reqwest::redirect::Policy::limited(5))
.build()
.map_err(|e| CloneError::Other(format!("HTTP client error: {e}")))?;
let ref_url = format!("https://api.github.com/repos/{repo}/git/ref/heads/main");
let ref_resp = http
.get(&ref_url)
.header("Authorization", format!("Bearer {token}"))
.header("Accept", "application/vnd.github+json")
.header("User-Agent", "x402-node")
.header("X-GitHub-Api-Version", "2022-11-28")
.send()
.await
.map_err(|e| CloneError::Other(format!("GitHub API error (get ref): {e}")))?;
if !ref_resp.status().is_success() {
let status = ref_resp.status();
let body = ref_resp.text().await.unwrap_or_default();
return Err(CloneError::Other(format!(
"GitHub GET ref failed (HTTP {status}): {}",
body.chars().take(200).collect::<String>()
)));
}
let ref_json: serde_json::Value = ref_resp
.json()
.await
.map_err(|e| CloneError::Other(format!("GitHub API parse error: {e}")))?;
let main_sha = ref_json["object"]["sha"]
.as_str()
.ok_or_else(|| CloneError::Other("missing SHA in GitHub ref response".to_string()))?;
let update_url = format!("https://api.github.com/repos/{repo}/git/refs/heads/{branch}");
let update_resp = http
.patch(&update_url)
.header("Authorization", format!("Bearer {token}"))
.header("Accept", "application/vnd.github+json")
.header("User-Agent", "x402-node")
.header("X-GitHub-Api-Version", "2022-11-28")
.json(&serde_json::json!({
"sha": main_sha,
"force": true,
}))
.send()
.await
.map_err(|e| CloneError::Other(format!("GitHub API error (update ref): {e}")))?;
if !update_resp.status().is_success() {
let status = update_resp.status();
let body = update_resp.text().await.unwrap_or_default();
return Err(CloneError::Other(format!(
"GitHub update branch failed (HTTP {status}): {}",
body.chars().take(200).collect::<String>()
)));
}
Ok(())
}
pub async fn delete_github_branch(token: &str, repo: &str, branch: &str) -> Result<(), CloneError> {
let http = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(15))
.redirect(reqwest::redirect::Policy::limited(5))
.build()
.map_err(|e| CloneError::Other(format!("HTTP client error: {e}")))?;
let url = format!("https://api.github.com/repos/{repo}/git/refs/heads/{branch}");
let resp = http
.delete(&url)
.header("Authorization", format!("Bearer {token}"))
.header("Accept", "application/vnd.github+json")
.header("User-Agent", "x402-node")
.header("X-GitHub-Api-Version", "2022-11-28")
.send()
.await
.map_err(|e| CloneError::Other(format!("GitHub API error (delete ref): {e}")))?;
if !resp.status().is_success() && resp.status().as_u16() != 404 {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(CloneError::Other(format!(
"GitHub delete branch failed (HTTP {status}): {body}"
)));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_clone_config_docker() {
let config = CloneConfig {
docker_image: Some("ghcr.io/compusophy/tempo-x402:latest".to_string()),
source_repo: None,
github_token: None,
rpc_url: "https://rpc.moderato.tempo.xyz".to_string(),
self_url: "https://my-instance.up.railway.app".to_string(),
max_children: 10,
clone_cpu_millicores: 2000,
clone_memory_mb: 2048,
child_env_vars: std::collections::HashMap::new(),
};
assert_eq!(config.max_children, 10);
assert_eq!(config.clone_cpu_millicores, 2000);
assert!(config.docker_image.is_some());
assert!(config.source_repo.is_none());
}
#[test]
fn test_clone_config_source() {
let config = CloneConfig {
docker_image: None,
source_repo: Some("compusophy-bot/tempo-x402".to_string()),
github_token: Some("ghp_test".to_string()),
rpc_url: "https://rpc.moderato.tempo.xyz".to_string(),
self_url: "https://my-instance.up.railway.app".to_string(),
max_children: 5,
clone_cpu_millicores: 2000,
clone_memory_mb: 2048,
child_env_vars: std::collections::HashMap::new(),
};
assert!(config.docker_image.is_none());
assert_eq!(
config.source_repo.as_deref(),
Some("compusophy-bot/tempo-x402")
);
}
#[test]
fn test_clone_error_display() {
let err = CloneError::LimitReached {
current: 10,
max: 10,
};
assert!(err.to_string().contains("10/10"));
}
}