use crate::commands::TrainArgs;
use crate::context::CliContext;
use crate::services::credentials::CredentialsService;
use crate::services::train_jobs::{load_job_catalog, JobDefinition};
use anyhow::{bail, Context, Result};
use dialoguer::{theme::ColorfulTheme, Select};
use serde::Deserialize;
use std::path::Path;
use std::process::Stdio;
const JOB_CREATE_PATH: &str = "/api/model-deployments/jobs";
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum DispatchTarget {
Local,
Remote,
}
pub async fn handle_train(ctx: &mut CliContext, args: &TrainArgs) -> Result<()> {
println!();
println!("Mecha10 Training");
println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
println!();
if !ctx.is_project_initialized() {
bail!("Not inside a mecha10 project (no mecha10.json found). Run `mecha10 init` first.");
}
let jobs = load_job_catalog(&ctx.working_dir).context("failed to load job catalog from configs/rl/jobs/")?;
if jobs.is_empty() {
println!("No training jobs found under configs/rl/jobs/.");
println!();
println!("A freshly scaffolded rover project ships a standard set (train,");
println!("train-from-checkpoint, hypertune) via `mecha10 init` - add your own YAML files");
println!("there to extend the menu. See configs/rl/local.yml for the underlying config.");
println!();
return Ok(());
}
let job = select_job(&jobs, args.job.as_deref())?;
let target = select_target(args.target.as_deref())?;
match target {
DispatchTarget::Local => run_local(ctx, job).await,
DispatchTarget::Remote => run_remote(ctx, job).await,
}
}
fn select_job<'a>(jobs: &'a [JobDefinition], requested: Option<&str>) -> Result<&'a JobDefinition> {
if let Some(name) = requested {
return jobs.iter().find(|j| j.name == name).with_context(|| {
format!(
"no job named '{name}' in configs/rl/jobs/ (available: {})",
jobs.iter().map(|j| j.name.as_str()).collect::<Vec<_>>().join(", ")
)
});
}
let items: Vec<String> = jobs
.iter()
.map(|j| match &j.description {
Some(desc) => format!("{} ({}) - {}", j.name, j.flow_type, desc),
None => format!("{} ({})", j.name, j.flow_type),
})
.collect();
let selection = Select::with_theme(&ColorfulTheme::default())
.with_prompt("Select a training job")
.items(&items)
.default(0)
.interact()
.map_err(|e| anyhow::anyhow!("failed to get job selection: {e}"))?;
Ok(&jobs[selection])
}
fn select_target(requested: Option<&str>) -> Result<DispatchTarget> {
if let Some(target) = requested {
return match target.to_lowercase().as_str() {
"local" => Ok(DispatchTarget::Local),
"remote" => Ok(DispatchTarget::Remote),
other => bail!("unknown --target '{other}' (expected 'local' or 'remote')"),
};
}
let items = ["Local (run on this machine)", "Remote (dispatch via Kestra/SaaS)"];
let selection = Select::with_theme(&ColorfulTheme::default())
.with_prompt("Where should this job run?")
.items(items)
.default(0)
.interact()
.map_err(|e| anyhow::anyhow!("failed to get dispatch target selection: {e}"))?;
Ok(if selection == 0 {
DispatchTarget::Local
} else {
DispatchTarget::Remote
})
}
async fn run_local(ctx: &mut CliContext, job: &JobDefinition) -> Result<()> {
println!("Running '{}' locally...", job.name);
println!();
let credentials = CredentialsService::new().load()?.context(
"Not logged in. Run `mecha10 auth login` first - local training still reports metrics \
through mecha10-api's relay, which needs an API key.",
)?;
let project_config = ctx.load_project_config().await?;
let control_plane_url = project_config.environments.control_plane_url();
let python = find_project_python(&ctx.working_dir)?;
let log_level = ctx.log_level.to_string().to_lowercase();
let env_vars = job.local_env_vars(&control_plane_url, &credentials.api_key, &log_level);
let mode = job.run_mode();
let mut command = tokio::process::Command::new(&python);
command
.args(["-m", "rl_task_runner.run", mode])
.current_dir(&ctx.working_dir)
.stdin(Stdio::inherit())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit());
for (key, value) in &env_vars {
command.env(key, value);
}
let status = command
.status()
.await
.with_context(|| format!("failed to spawn '{python} -m rl_task_runner.run {mode}'"))?;
if !status.success() {
bail!(
"training job '{}' exited with {}",
job.name,
status
.code()
.map_or_else(|| "a signal".to_string(), |code| code.to_string())
);
}
println!();
println!("Training job '{}' finished.", job.name);
Ok(())
}
fn find_project_python(project_root: &Path) -> Result<String> {
let venv_python = if cfg!(windows) {
project_root.join(".venv").join("Scripts").join("python.exe")
} else {
project_root.join(".venv").join("bin").join("python")
};
if venv_python.exists() {
return Ok(venv_python.display().to_string());
}
for candidate in ["python3", "python"] {
if let Ok(output) = std::process::Command::new(candidate).arg("--version").output() {
let version = format!(
"{}{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
if output.status.success() && version.contains("Python 3") {
return Ok(candidate.to_string());
}
}
}
bail!(
"Python 3 not found (checked {} and PATH). Run `mecha10 setup` to create a project venv \
with rl-task-runner installed.",
venv_python.display()
)
}
#[derive(Debug, Deserialize)]
struct CreateJobResponse {
id: String,
name: String,
state: String,
mlflow_run_id: Option<String>,
mlflow_experiment: Option<String>,
kestra_execution_id: Option<String>,
}
async fn run_remote(ctx: &mut CliContext, job: &JobDefinition) -> Result<()> {
if !job.supports_remote() {
bail!(
"job '{}' uses flow_type '{}', which mecha10-api's job-creation endpoint doesn't \
dispatch yet (only the plain 'train' flow is wired server-side today - see \
mecha10-api/src/jobs/handlers.rs::train_flow_inputs). Run it locally instead, or \
add a plain 'train' job definition.",
job.name,
job.flow_type
);
}
if job.algorithm.as_deref().unwrap_or_default().trim().is_empty() {
bail!(
"job '{}' has no 'algorithm' set, which is required for remote dispatch \
(mecha10-api's CreateJobRequest.algorithm is mandatory). Set 'algorithm:' in its \
YAML, or run it locally, where the config file's own algorithm_name is used instead.",
job.name
);
}
println!("Dispatching '{}' remotely via Kestra...", job.name);
println!();
let credentials = CredentialsService::new()
.load()?
.context("Not logged in. Run `mecha10 auth login` first.")?;
let project_config = ctx.load_project_config().await?;
let control_plane_url = project_config.environments.control_plane_url();
let url = format!("{}{}", control_plane_url, JOB_CREATE_PATH);
let request = job.to_create_job_request(&project_config.robot.id);
let client = reqwest::Client::new();
let response = client
.post(&url)
.bearer_auth(&credentials.api_key)
.json(&request)
.send()
.await
.context("failed to reach mecha10-api")?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
bail!("job creation failed ({status}): {body}");
}
let created: CreateJobResponse = response.json().await.context("failed to parse job creation response")?;
println!("Job created: {} ({})", created.name, created.id);
println!(" State: {}", created.state);
if let Some(execution_id) = &created.kestra_execution_id {
println!(" Kestra execution: {}", execution_id);
}
if let Some(run_id) = &created.mlflow_run_id {
println!(" MLflow run: {}", run_id);
}
if let Some(experiment) = &created.mlflow_experiment {
println!(" MLflow experiment: {}", experiment);
}
println!();
println!("Check the dashboard's ml-training job list for live progress.");
println!();
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::services::train_jobs::FlowType;
use std::path::PathBuf;
fn sample_job(flow_type: FlowType) -> JobDefinition {
JobDefinition {
name: "train".to_string(),
description: Some("Train from scratch".to_string()),
flow_type,
config: "local".to_string(),
env_name: Some("rover".to_string()),
algorithm: Some("ppo".to_string()),
timesteps: None,
n_steps: None,
epochs: None,
lr: None,
patience: None,
min_delta: None,
resume_run_id: None,
tune_run_id: None,
n_trials: None,
tune_timesteps: None,
eval_eps: None,
source_path: PathBuf::new(),
}
}
#[test]
fn select_job_by_name_finds_exact_match() {
let jobs = vec![sample_job(FlowType::Train)];
let job = select_job(&jobs, Some("train")).unwrap();
assert_eq!(job.name, "train");
}
#[test]
fn select_job_by_name_errors_with_available_names_when_missing() {
let jobs = vec![sample_job(FlowType::Train)];
let err = select_job(&jobs, Some("nonexistent")).unwrap_err();
assert!(err.to_string().contains("train"));
}
#[test]
fn select_target_parses_local_and_remote_case_insensitively() {
assert_eq!(select_target(Some("local")).unwrap(), DispatchTarget::Local);
assert_eq!(select_target(Some("REMOTE")).unwrap(), DispatchTarget::Remote);
}
#[test]
fn select_target_rejects_unknown_value() {
let err = select_target(Some("saas")).unwrap_err();
assert!(err.to_string().contains("saas"));
}
#[test]
fn find_project_python_falls_back_to_path_when_no_venv() {
let temp = tempfile::TempDir::new().unwrap();
let python = find_project_python(temp.path());
assert!(python.is_ok());
}
}