mecha10-cli 4.2.14

Mecha10 CLI tool
Documentation
//! HTTP client for mecha10-api's build-jobs endpoint (LAB-5443), used by the CLI's
//! remote/dashboard build flow (LAB-5442, part of "build via mecha10 dashboard", LAB-2153).
//!
//! Mirrors the request/response shapes pinned in mecha10-api's `jobs::build_jobs` module
//! (`services/api/src/jobs/build_jobs.rs`) and implemented by
//! `services/api/src/robot/build_jobs/handlers.rs`: a multipart `POST
//! /api/robots/{robot_id}/build-jobs` (the `.mecha10ignore`-filtered zip under
//! `build_source::BUILD_SOURCE_UPLOAD_FIELD`, plus `target_platform`/`target_arch`/optional
//! `config` sidecar fields) and a `GET /api/robots/{robot_id}/build-jobs/{id}` poll endpoint.
//! Auth is the caller's own long-lived `mecha_...` API key as a bearer token on both calls -
//! no new auth mechanism (`jobs::auth::AuthUser`'s `FromRequestParts` impl only looks at the
//! `Authorization: Bearer <token>` header, same as every other authenticated CLI command).

use crate::services::build_source::{BUILD_SOURCE_UPLOAD_CONTENT_TYPE, BUILD_SOURCE_UPLOAD_FIELD};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::Path;

/// Build-job lifecycle state - matches mecha10-api's `BuildJobState` wire format exactly
/// (`#[serde(rename_all = "snake_case")]`, no `cancelled` variant - a build job, like an L6
/// dataset build, has no cancel route).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum BuildJobState {
    Pending,
    Running,
    Succeeded,
    Failed,
}

/// `POST /api/robots/{robot_id}/build-jobs` response body - matches mecha10-api's
/// `CreateBuildJobResponse`. Only `id` is modeled: it's the only field the caller reads (to
/// report the created job's id and to start polling it); `state`/`message` describe the
/// freshly-created job's initial status, which the poll loop immediately re-fetches anyway.
#[derive(Debug, Clone, Deserialize)]
pub struct CreateBuildJobResponse {
    pub id: String,
}

/// `GET /api/robots/{robot_id}/build-jobs/{id}` response body - matches mecha10-api's
/// `BuildJob`/`GetBuildJobResponse` shape. Only the fields the CLI's poll loop surfaces are
/// modeled here; `id`/`binary_id`/timestamps/etc. aren't needed for v0 (the poll loop already
/// has the job id from `create_build_job`, and just reports success/failure with a progress
/// line, not the resulting binary's registry id).
#[derive(Debug, Clone, Deserialize)]
pub struct BuildJob {
    pub state: BuildJobState,
    #[serde(default)]
    pub error_message: Option<String>,
    #[serde(default)]
    pub progress_message: Option<String>,
}

/// Inputs to [`create_build_job`], grouped into one struct rather than a long positional
/// argument list (same convention as `handlers/upload.rs`'s `UploadParams`).
pub struct CreateBuildJobParams<'a> {
    pub control_plane_url: &'a str,
    pub robot_id: &'a str,
    pub target_platform: &'a str,
    pub target_arch: &'a str,
    pub config: Option<&'a str>,
    pub api_key: &'a str,
    pub zip_path: &'a Path,
}

/// Shared URL builder for both the create and poll endpoints - one place that knows the
/// route shape (`/api/robots/{robot_id}/build-jobs[...]`), matching mecha10-api's
/// `robot::build_jobs::router()`.
fn build_jobs_url(control_plane_url: &str, robot_id: &str) -> String {
    format!(
        "{}/api/robots/{}/build-jobs",
        control_plane_url.trim_end_matches('/'),
        robot_id
    )
}

/// `POST /api/robots/{robot_id}/build-jobs`: upload the already-constructed, already
/// size-checked project source zip plus the build's sidecar fields, and return the created
/// job. The caller's own long-lived API key is sent as a bearer token - the same credential
/// every other authenticated CLI command already uses, not a new per-job token.
pub async fn create_build_job(params: CreateBuildJobParams<'_>) -> Result<CreateBuildJobResponse> {
    let CreateBuildJobParams {
        control_plane_url,
        robot_id,
        target_platform,
        target_arch,
        config,
        api_key,
        zip_path,
    } = params;

    let zip_bytes = tokio::fs::read(zip_path)
        .await
        .with_context(|| format!("Failed to read {}", zip_path.display()))?;

    let file_part = reqwest::multipart::Part::bytes(zip_bytes)
        .file_name("source.zip".to_string())
        .mime_str(BUILD_SOURCE_UPLOAD_CONTENT_TYPE)
        .context("Failed to set source zip content type")?;

    let mut form = reqwest::multipart::Form::new()
        .text("robot_id", robot_id.to_string())
        .text("target_platform", target_platform.to_string())
        .text("target_arch", target_arch.to_string())
        .part(BUILD_SOURCE_UPLOAD_FIELD, file_part);

    if let Some(config) = config {
        form = form.text("config", config.to_string());
    }

    let client = reqwest::Client::new();
    let response = client
        .post(build_jobs_url(control_plane_url, robot_id))
        .bearer_auth(api_key)
        .multipart(form)
        .send()
        .await
        .context("Failed to send build-job create request")?;

    let status = response.status();
    let body = response
        .text()
        .await
        .context("Failed to read build-job create response")?;

    if !status.is_success() {
        anyhow::bail!("Failed to create build job (status {status}): {body}");
    }

    serde_json::from_str(&body).context("Failed to parse build-job create response")
}

/// `GET /api/robots/{robot_id}/build-jobs/{id}`: fetch the current state of a build job -
/// the CLI's poll loop calls this on an interval until `state` is terminal.
pub async fn get_build_job(control_plane_url: &str, robot_id: &str, id: &str, api_key: &str) -> Result<BuildJob> {
    let url = format!("{}/{}", build_jobs_url(control_plane_url, robot_id), id);

    let client = reqwest::Client::new();
    let response = client
        .get(url)
        .bearer_auth(api_key)
        .send()
        .await
        .context("Failed to send build-job poll request")?;

    let status = response.status();
    let body = response
        .text()
        .await
        .context("Failed to read build-job poll response")?;

    if !status.is_success() {
        anyhow::bail!("Failed to poll build job (status {status}): {body}");
    }

    serde_json::from_str(&body).context("Failed to parse build-job poll response")
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::io::{Read, Write};
    use tempfile::tempdir;

    #[test]
    fn build_job_state_serializes_to_snake_case_matching_the_server_contract() {
        assert_eq!(serde_json::to_string(&BuildJobState::Pending).unwrap(), "\"pending\"");
        assert_eq!(serde_json::to_string(&BuildJobState::Running).unwrap(), "\"running\"");
        assert_eq!(
            serde_json::to_string(&BuildJobState::Succeeded).unwrap(),
            "\"succeeded\""
        );
        assert_eq!(serde_json::to_string(&BuildJobState::Failed).unwrap(), "\"failed\"");
    }

    #[test]
    fn build_jobs_url_joins_control_plane_url_and_robot_id() {
        assert_eq!(
            build_jobs_url("https://api.mecha10.dev", "robot-1"),
            "https://api.mecha10.dev/api/robots/robot-1/build-jobs"
        );
    }

    #[test]
    fn build_jobs_url_trims_a_trailing_slash_on_the_control_plane_url() {
        assert_eq!(
            build_jobs_url("https://api.mecha10.dev/", "robot-1"),
            "https://api.mecha10.dev/api/robots/robot-1/build-jobs"
        );
    }

    /// Minimal fake HTTP server that always responds `response_body` with `status_line` to
    /// any request - same pattern as `handlers/build.rs`'s `FakeAssetsServer`, trimmed down
    /// since these tests don't need to inspect the request at all (the server-side multipart
    /// parsing is mecha10-api's responsibility, pinned by its own tests, not this repo's).
    fn start_fake_server(status_line: &'static str, response_body: String) -> String {
        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
        let addr = listener.local_addr().unwrap();

        std::thread::spawn(move || {
            for stream in listener.incoming() {
                let mut stream = match stream {
                    Ok(s) => s,
                    Err(_) => continue,
                };
                let mut buf = [0u8; 65536];
                let _ = stream.read(&mut buf);
                let header = format!(
                    "{status_line}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
                    response_body.len()
                );
                let _ = stream.write_all(header.as_bytes());
                let _ = stream.write_all(response_body.as_bytes());
                let _ = stream.flush();
            }
        });

        format!("http://{}", addr)
    }

    #[tokio::test]
    async fn create_build_job_returns_the_parsed_response_on_success() {
        let body = serde_json::json!({"id": "job-1", "robot_id": "robot-1", "state": "pending", "message": "Build job queued"}).to_string();
        let url = start_fake_server("HTTP/1.1 200 OK", body);

        let dir = tempdir().unwrap();
        let zip_path = dir.path().join("source.zip");
        std::fs::write(&zip_path, b"fake zip bytes").unwrap();

        let result = create_build_job(CreateBuildJobParams {
            control_plane_url: &url,
            robot_id: "robot-1",
            target_platform: "linux",
            target_arch: "aarch64",
            config: None,
            api_key: "mecha_test_key",
            zip_path: &zip_path,
        })
        .await
        .unwrap();

        assert_eq!(result.id, "job-1");
    }

    #[tokio::test]
    async fn create_build_job_surfaces_the_servers_error_body_on_failure() {
        let url = start_fake_server(
            "HTTP/1.1 400 Bad Request",
            serde_json::json!({"error": "'source' exceeds the 1073741824-byte limit"}).to_string(),
        );

        let dir = tempdir().unwrap();
        let zip_path = dir.path().join("source.zip");
        std::fs::write(&zip_path, b"fake zip bytes").unwrap();

        let err = create_build_job(CreateBuildJobParams {
            control_plane_url: &url,
            robot_id: "robot-1",
            target_platform: "linux",
            target_arch: "aarch64",
            config: None,
            api_key: "mecha_test_key",
            zip_path: &zip_path,
        })
        .await
        .unwrap_err();

        assert!(err.to_string().contains("exceeds the"));
    }

    #[tokio::test]
    async fn get_build_job_returns_the_parsed_response_on_success() {
        let body = serde_json::json!({
            "id": "job-1",
            "state": "running",
            "error_message": null,
            "progress_message": "compiling"
        })
        .to_string();
        let url = start_fake_server("HTTP/1.1 200 OK", body);

        let result = get_build_job(&url, "robot-1", "job-1", "mecha_test_key").await.unwrap();

        assert_eq!(result.state, BuildJobState::Running);
        assert_eq!(result.progress_message, Some("compiling".to_string()));
    }

    #[tokio::test]
    async fn get_build_job_surfaces_the_servers_error_on_failure() {
        let url = start_fake_server(
            "HTTP/1.1 404 Not Found",
            serde_json::json!({"error": "build job job-1 not found"}).to_string(),
        );

        let err = get_build_job(&url, "robot-1", "job-1", "mecha_test_key")
            .await
            .unwrap_err();

        assert!(err.to_string().contains("not found"));
    }
}