use crate::paths;
use anyhow::{Context, Result};
use std::path::Path;
use tokio::io::AsyncWriteExt;
const RL_LOCAL_CONFIG: &str = include_str!("../../templates/rl/local.yml.template");
const RL_REQUIREMENTS: &str = include_str!("../../templates/rl/requirements-rl.txt.template");
const KESTRA_README: &str = include_str!("../../templates/rl/kestra-README.md.template");
const KESTRA_FLOW_TRAIN: &str = include_str!("../../templates/rl/kestra-flows/train.yml.template");
const KESTRA_FLOW_TUNE: &str = include_str!("../../templates/rl/kestra-flows/tune.yml.template");
const KESTRA_FLOW_TUNE_THEN_TRAIN: &str = include_str!("../../templates/rl/kestra-flows/tune-then-train.yml.template");
const KESTRA_FLOW_TRAIN_FROM_TUNE: &str = include_str!("../../templates/rl/kestra-flows/train-from-tune.yml.template");
const KESTRA_FLOW_RESUME_RUN: &str = include_str!("../../templates/rl/kestra-flows/resume-run.yml.template");
const JOB_TRAIN: &str = include_str!("../../templates/rl/jobs/train.yml.template");
const JOB_TRAIN_FROM_CHECKPOINT: &str = include_str!("../../templates/rl/jobs/train-from-checkpoint.yml.template");
const JOB_HYPERTUNE: &str = include_str!("../../templates/rl/jobs/hypertune.yml.template");
const RL_CONFIG_DIR: &str = "configs/rl";
const RL_LOCAL_CONFIG_PATH: &str = "configs/rl/local.yml";
const RL_JOBS_DIR: &str = "configs/rl/jobs";
const RL_JOBS: [(&str, &str); 3] = [
("train.yml", JOB_TRAIN),
("train-from-checkpoint.yml", JOB_TRAIN_FROM_CHECKPOINT),
("hypertune.yml", JOB_HYPERTUNE),
];
const KESTRA_DIR: &str = "kestra";
const KESTRA_README_PATH: &str = "kestra/README.md";
const KESTRA_FLOWS_DIR: &str = "kestra/flows/ml-pipeline";
const KESTRA_FLOWS: [(&str, &str); 5] = [
("train.yml", KESTRA_FLOW_TRAIN),
("tune.yml", KESTRA_FLOW_TUNE),
("tune-then-train.yml", KESTRA_FLOW_TUNE_THEN_TRAIN),
("train-from-tune.yml", KESTRA_FLOW_TRAIN_FROM_TUNE),
("resume-run.yml", KESTRA_FLOW_RESUME_RUN),
];
pub struct RlBundleTemplates;
impl RlBundleTemplates {
pub fn new() -> Self {
Self
}
pub async fn create_rl_config(&self, path: &Path) -> Result<()> {
let dest_dir = path.join(RL_CONFIG_DIR);
tokio::fs::create_dir_all(&dest_dir)
.await
.with_context(|| format!("Failed to create {}", RL_CONFIG_DIR))?;
tokio::fs::write(path.join(RL_LOCAL_CONFIG_PATH), RL_LOCAL_CONFIG)
.await
.context("Failed to write configs/rl/local.yml")?;
Ok(())
}
pub async fn create_rl_jobs(&self, path: &Path) -> Result<()> {
let jobs_dir = path.join(RL_JOBS_DIR);
tokio::fs::create_dir_all(&jobs_dir)
.await
.with_context(|| format!("Failed to create {}", RL_JOBS_DIR))?;
for (file_name, content) in RL_JOBS {
tokio::fs::write(jobs_dir.join(file_name), content)
.await
.with_context(|| format!("Failed to write {}/{}", RL_JOBS_DIR, file_name))?;
}
Ok(())
}
pub async fn append_rl_requirements(&self, path: &Path) -> Result<()> {
let requirements_path = path.join(paths::meta::REQUIREMENTS_TXT);
let mut file = tokio::fs::OpenOptions::new()
.append(true)
.create(true)
.open(&requirements_path)
.await
.context("Failed to open requirements.txt for appending RL bundle dependencies")?;
file.write_all(RL_REQUIREMENTS.as_bytes())
.await
.context("Failed to append RL bundle dependencies to requirements.txt")?;
Ok(())
}
pub async fn create_kestra_flows(&self, path: &Path) -> Result<()> {
let dest_dir = path.join(KESTRA_DIR);
tokio::fs::create_dir_all(&dest_dir)
.await
.with_context(|| format!("Failed to create {}", KESTRA_DIR))?;
tokio::fs::write(path.join(KESTRA_README_PATH), KESTRA_README)
.await
.context("Failed to write kestra/README.md")?;
let flows_dir = path.join(KESTRA_FLOWS_DIR);
tokio::fs::create_dir_all(&flows_dir)
.await
.with_context(|| format!("Failed to create {}", KESTRA_FLOWS_DIR))?;
for (file_name, content) in KESTRA_FLOWS {
tokio::fs::write(flows_dir.join(file_name), content)
.await
.with_context(|| format!("Failed to write {}/{}", KESTRA_FLOWS_DIR, file_name))?;
}
Ok(())
}
}
impl Default for RlBundleTemplates {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[tokio::test]
async fn test_create_rl_config_writes_expected_defaults() {
let temp = TempDir::new().unwrap();
let bundle = RlBundleTemplates::new();
bundle.create_rl_config(temp.path()).await.unwrap();
let content = tokio::fs::read_to_string(temp.path().join("configs/rl/local.yml"))
.await
.unwrap();
assert!(content.contains("env: rover"));
assert!(content.contains("algorithm: ppo"));
}
#[tokio::test]
async fn test_create_rl_jobs_writes_standard_catalog() {
let temp = TempDir::new().unwrap();
let bundle = RlBundleTemplates::new();
bundle.create_rl_jobs(temp.path()).await.unwrap();
for file_name in ["train.yml", "train-from-checkpoint.yml", "hypertune.yml"] {
let job_path = temp.path().join("configs/rl/jobs").join(file_name);
let content = tokio::fs::read_to_string(&job_path)
.await
.unwrap_or_else(|e| panic!("failed to read {}: {}", job_path.display(), e));
assert!(content.contains("name:"), "{file_name} missing name:");
assert!(content.contains("flow_type:"), "{file_name} missing flow_type:");
assert!(content.contains("config: local"), "{file_name} missing config: local");
}
}
#[tokio::test]
async fn test_create_rl_jobs_train_maps_to_the_train_flow() {
let temp = TempDir::new().unwrap();
let bundle = RlBundleTemplates::new();
bundle.create_rl_jobs(temp.path()).await.unwrap();
let content = tokio::fs::read_to_string(temp.path().join("configs/rl/jobs/train.yml"))
.await
.unwrap();
assert!(content.contains("flow_type: train"));
assert!(content.contains("algorithm: ppo"));
}
#[tokio::test]
async fn test_create_rl_jobs_train_from_checkpoint_maps_to_resume_run_flow() {
let temp = TempDir::new().unwrap();
let bundle = RlBundleTemplates::new();
bundle.create_rl_jobs(temp.path()).await.unwrap();
let content = tokio::fs::read_to_string(temp.path().join("configs/rl/jobs/train-from-checkpoint.yml"))
.await
.unwrap();
assert!(content.contains("flow_type: resume-run"));
assert!(content.contains("resume_run_id"));
}
#[tokio::test]
async fn test_create_rl_jobs_hypertune_maps_to_tune_flow() {
let temp = TempDir::new().unwrap();
let bundle = RlBundleTemplates::new();
bundle.create_rl_jobs(temp.path()).await.unwrap();
let content = tokio::fs::read_to_string(temp.path().join("configs/rl/jobs/hypertune.yml"))
.await
.unwrap();
assert!(content.contains("flow_type: tune"));
assert!(content.contains("n_trials"));
}
#[tokio::test]
async fn test_append_rl_requirements_preserves_existing_content() {
let temp = TempDir::new().unwrap();
let bundle = RlBundleTemplates::new();
tokio::fs::write(temp.path().join("requirements.txt"), "onnx>=1.15.0\n")
.await
.unwrap();
bundle.append_rl_requirements(temp.path()).await.unwrap();
let content = tokio::fs::read_to_string(temp.path().join("requirements.txt"))
.await
.unwrap();
assert!(content.contains("onnx>=1.15.0"));
assert!(content.contains("mecha10-rl-task-runner"));
assert!(content.contains("mecha10-rl "));
}
#[tokio::test]
async fn test_append_rl_requirements_creates_file_if_missing() {
let temp = TempDir::new().unwrap();
let bundle = RlBundleTemplates::new();
bundle.append_rl_requirements(temp.path()).await.unwrap();
let content = tokio::fs::read_to_string(temp.path().join("requirements.txt"))
.await
.unwrap();
assert!(content.contains("mecha10-rl-task-runner"));
}
#[tokio::test]
async fn test_create_kestra_flows_writes_readme() {
let temp = TempDir::new().unwrap();
let bundle = RlBundleTemplates::new();
bundle.create_kestra_flows(temp.path()).await.unwrap();
let content = tokio::fs::read_to_string(temp.path().join("kestra/README.md"))
.await
.unwrap();
assert!(!content.contains("not yet part of this bundle"));
assert!(content.contains("train"));
assert!(content.contains("env_name"));
assert!(content.contains("algorithm_name"));
}
#[tokio::test]
async fn test_create_kestra_flows_writes_all_five_flows_generically_parameterized() {
let temp = TempDir::new().unwrap();
let bundle = RlBundleTemplates::new();
bundle.create_kestra_flows(temp.path()).await.unwrap();
for file_name in [
"train.yml",
"tune.yml",
"tune-then-train.yml",
"train-from-tune.yml",
"resume-run.yml",
] {
let flow_path = temp.path().join("kestra/flows/ml-pipeline").join(file_name);
let content = tokio::fs::read_to_string(&flow_path)
.await
.unwrap_or_else(|e| panic!("failed to read {}: {}", flow_path.display(), e));
assert!(content.contains("id: env_name"), "{file_name} missing env_name input");
assert!(
content.contains("id: algorithm_name"),
"{file_name} missing algorithm_name input"
);
assert!(content.contains("secret("), "{file_name} missing secret() usage");
}
}
#[tokio::test]
async fn test_create_kestra_flows_train_matches_source_flow() {
let temp = TempDir::new().unwrap();
let bundle = RlBundleTemplates::new();
bundle.create_kestra_flows(temp.path()).await.unwrap();
let content = tokio::fs::read_to_string(temp.path().join("kestra/flows/ml-pipeline/train.yml"))
.await
.unwrap();
assert!(content.contains("id: train"));
assert!(content.contains("namespace: mecha10.ml-pipeline"));
}
}