mecha10-cli 4.2.14

Mecha10 CLI tool
Documentation
//! Remote/dashboard build flow (LAB-5442, part of "build via mecha10 dashboard", LAB-2153):
//! zips the project (respecting `.mecha10ignore`), uploads it to mecha10-api's build-jobs
//! endpoint, then polls until the dispatched build reaches a terminal state.
//!
//! Split out of `handlers/build.rs` (which already orchestrates the local-compile path) so
//! this entirely different flow - no `cargo build` at all, the zip is cross-compiled
//! server-side - doesn't get tangled into that function's control flow.

use crate::commands::BuildTargetArgs;
use crate::context::CliContext;
use crate::handlers::build::BuildTarget;
use crate::services::build_job_client::{self, BuildJobState};
use crate::services::build_source;
use crate::services::credentials::CredentialsService;
use crate::ui::style;
use anyhow::{Context, Result};
use std::time::Duration;
use tempfile::tempdir;

/// Poll interval for the build-job status endpoint (LAB-5440 decision: "poll every 2-5
/// seconds").
const POLL_INTERVAL: Duration = Duration::from_secs(3);

/// Entry point for `mecha10 build <target> --location dashboard` (or its interactive
/// equivalent): package the project, upload it to mecha10-api, and poll until the build
/// finishes. Builds do not pull models (`crate::jobs::build_jobs`'s module doc comment on the
/// server side) - this flow never touches model-handling logic.
pub async fn build_via_dashboard(ctx: &CliContext, target: BuildTarget, args: &BuildTargetArgs) -> Result<()> {
    println!();
    println!("{}", style::heading("☁️  Building via the mecha10 dashboard"));
    println!("{}", style::separator(47));
    println!();

    let project_config = ctx.load_project_config().await?;
    let control_plane_url = project_config.environments.control_plane_url();
    let robot_id = project_config.robot.id.clone();

    let credentials_service = CredentialsService::new();
    let credentials = mecha10_auth::ensure_authenticated_interactive(&credentials_service)
        .await
        .context("Remote builds require you to be logged in")?;

    let (target_platform, target_arch) = platform_arch_for_target(args.target.as_deref());

    println!("Robot ID:        {}", robot_id);
    println!("Target:          {} ({})", target.display_name(), target.as_str());
    println!("Target platform: {}", target_platform);
    println!("Target arch:     {}", target_arch);
    println!();

    // `_temp_dir` is held alive until after the upload below so the zip isn't cleaned up out
    // from under `create_build_job` - it's dropped (deleting the temp dir) once this
    // function returns, since nothing after the upload needs the zip file itself anymore.
    let (_temp_dir, zip_path) = package_project_source(&ctx.working_dir).await?;
    println!();

    println!("Uploading source and creating build job...");
    let created = build_job_client::create_build_job(build_job_client::CreateBuildJobParams {
        control_plane_url: &control_plane_url,
        robot_id: &robot_id,
        target_platform: &target_platform,
        target_arch: &target_arch,
        config: None,
        api_key: &credentials.api_key,
        zip_path: &zip_path,
    })
    .await
    .context("Failed to create build job")?;

    println!("{}", style::success(format!("✅ Build job created: {}", created.id)));
    println!();

    poll_until_terminal(&control_plane_url, &robot_id, &created.id, &credentials.api_key).await
}

/// Build the `.mecha10ignore`-filtered project zip into a fresh temp dir, failing fast (per
/// LAB-5440 decision #1) if it exceeds the server's size cap before ever attempting the
/// upload. Zip construction walks the filesystem synchronously, so it runs on a blocking
/// task rather than the async executor. Returns the `TempDir` guard alongside the zip's
/// path - the caller must keep it alive until it's done reading the zip, since dropping it
/// deletes the directory.
async fn package_project_source(project_root: &std::path::Path) -> Result<(tempfile::TempDir, std::path::PathBuf)> {
    println!(
        "Packaging project source ({} applied)...",
        build_source::IGNORE_FILE_NAME
    );

    let temp_dir = tempdir().context("Failed to create a temp directory for the source zip")?;
    let zip_path = temp_dir.path().join("source.zip");
    let project_root = project_root.to_path_buf();
    let zip_path_for_blocking = zip_path.clone();

    let zip_size = tokio::task::spawn_blocking(move || -> Result<u64> {
        let ignore_set = build_source::build_ignore_set(&project_root)?;
        let file = std::fs::File::create(&zip_path_for_blocking)
            .with_context(|| format!("Failed to create {}", zip_path_for_blocking.display()))?;
        build_source::write_project_zip(&project_root, &ignore_set, file)?;
        std::fs::metadata(&zip_path_for_blocking)
            .map(|m| m.len())
            .context("Failed to read constructed zip's size")
    })
    .await
    .context("Zip-packaging task panicked")??;

    println!("  Source zip: {} ({} bytes)", zip_path.display(), zip_size);
    build_source::check_zip_size(zip_size)?;

    Ok((temp_dir, zip_path))
}

/// Derive `(target_platform, target_arch)` for the build-job sidecar fields from an optional
/// cross-compile target triple (`BuildTargetArgs::target`), falling back to the host
/// platform/arch when building for the host. Matches the vocabulary mecha10-api's
/// `catalog::binaries::types::Binary::platform`/`arch` already use (e.g. `linux`/`aarch64`) -
/// the same vocabulary `handlers/upload.rs::detect_arch` targets for local builds.
fn platform_arch_for_target(target_triple: Option<&str>) -> (String, String) {
    match target_triple {
        Some(triple) => {
            let arch = triple.split('-').next().unwrap_or(triple).to_string();
            let platform = if triple.contains("-linux-") {
                "linux"
            } else if triple.contains("-darwin") {
                "macos"
            } else if triple.contains("-windows-") {
                "windows"
            } else {
                "linux"
            };
            (platform.to_string(), arch)
        }
        None => (host_platform(), std::env::consts::ARCH.to_string()),
    }
}

/// Host OS name in the vocabulary `platform_arch_for_target` uses - `std::env::consts::OS`
/// already returns `"linux"`/`"macos"`/`"windows"`, so this only exists to name the mapping
/// at the call site.
fn host_platform() -> String {
    std::env::consts::OS.to_string()
}

/// Poll `GET .../build-jobs/{id}` on [`POLL_INTERVAL`] until the job reaches a terminal
/// state, printing progress whenever it changes. Returns `Err` on a failed build (surfacing
/// the server's `error_message`) so the CLI's exit code reflects the failure.
async fn poll_until_terminal(control_plane_url: &str, robot_id: &str, id: &str, api_key: &str) -> Result<()> {
    let mut last_reported: Option<String> = None;

    loop {
        let job = build_job_client::get_build_job(control_plane_url, robot_id, id, api_key)
            .await
            .context("Failed to poll build job status")?;

        let status_line = format_status_line(&job);
        if last_reported.as_deref() != Some(status_line.as_str()) {
            println!("Status: {}", status_line);
            last_reported = Some(status_line);
        }

        match job.state {
            BuildJobState::Succeeded => {
                println!();
                println!("{}", style::success("✅ Build succeeded!"));
                println!("The binary is now available in the mecha10 binary registry.");
                println!();
                return Ok(());
            }
            BuildJobState::Failed => {
                println!();
                println!("{}", style::error("❌ Build failed"));
                if let Some(msg) = job.error_message.as_deref() {
                    println!("{}", style::error(msg));
                }
                println!();
                return Err(anyhow::anyhow!(
                    "Remote build failed{}",
                    job.error_message
                        .as_deref()
                        .map(|m| format!(": {m}"))
                        .unwrap_or_default()
                ));
            }
            BuildJobState::Pending | BuildJobState::Running => {
                tokio::time::sleep(POLL_INTERVAL).await;
            }
        }
    }
}

/// Human-readable one-line status for a poll response - `<state>` optionally followed by
/// ` - <progress_message>`. Split out so the "only print when it changes" dedup logic in
/// [`poll_until_terminal`] is comparing a plain string, and so this formatting is
/// unit-testable without a network call.
fn format_status_line(job: &build_job_client::BuildJob) -> String {
    let state_label = match job.state {
        BuildJobState::Pending => "pending",
        BuildJobState::Running => "running",
        BuildJobState::Succeeded => "succeeded",
        BuildJobState::Failed => "failed",
    };

    match job.progress_message.as_deref() {
        Some(message) if !message.is_empty() => format!("{state_label} - {message}"),
        _ => state_label.to_string(),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn platform_arch_for_target_falls_back_to_host_when_no_triple_given() {
        let (platform, arch) = platform_arch_for_target(None);
        assert_eq!(platform, std::env::consts::OS);
        assert_eq!(arch, std::env::consts::ARCH);
    }

    #[test]
    fn platform_arch_for_target_parses_a_linux_gnu_triple() {
        let (platform, arch) = platform_arch_for_target(Some("aarch64-unknown-linux-gnu"));
        assert_eq!(platform, "linux");
        assert_eq!(arch, "aarch64");
    }

    #[test]
    fn platform_arch_for_target_parses_a_linux_musl_triple() {
        let (platform, arch) = platform_arch_for_target(Some("x86_64-unknown-linux-musl"));
        assert_eq!(platform, "linux");
        assert_eq!(arch, "x86_64");
    }

    #[test]
    fn platform_arch_for_target_parses_a_darwin_triple() {
        let (platform, arch) = platform_arch_for_target(Some("aarch64-apple-darwin"));
        assert_eq!(platform, "macos");
        assert_eq!(arch, "aarch64");
    }

    fn job(state: BuildJobState, progress: Option<&str>) -> build_job_client::BuildJob {
        build_job_client::BuildJob {
            state,
            error_message: None,
            progress_message: progress.map(str::to_string),
        }
    }

    #[test]
    fn format_status_line_without_progress_message_is_just_the_state() {
        assert_eq!(format_status_line(&job(BuildJobState::Pending, None)), "pending");
    }

    #[test]
    fn format_status_line_appends_a_non_empty_progress_message() {
        assert_eq!(
            format_status_line(&job(BuildJobState::Running, Some("compiling"))),
            "running - compiling"
        );
    }

    #[test]
    fn format_status_line_ignores_an_empty_progress_message() {
        assert_eq!(format_status_line(&job(BuildJobState::Running, Some(""))), "running");
    }
}