greentic-flow-builder 0.4.0

Greentic Flow Builder — orchestrator that powers Adaptive Card design via the adaptive-card-mcp toolkit
Documentation
//! Background pipeline for wizard build jobs.
//! Separated from wizard.rs to keep each file under 500 lines.

use crate::orchestrate::deployer;
use crate::orchestrate::http_inject::HttpNodeEntry;
use crate::ui::routes::wizard::{download_url, find_file_in_dir, zip_directory};
use crate::ui::state::{AppState, LogKind, PackLogLine, WizardJobStatus, WizardMode};
use std::path::Path;
use std::sync::Arc;
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::Command;

// ── Public entry point ────────────────────────────────────────────────────────

#[allow(clippy::too_many_arguments)]
pub async fn run_wizard_pipeline(
    state: &Arc<AppState>,
    job_id: &str,
    args: &[String],
    out_dir: &Path,
    pack_name: &str,
    providers: &[String],
    mode: &WizardMode,
    cloud: Option<&str>,
    http_entries: &[HttpNodeEntry],
) {
    // ── Step 1: greentic-cards2pack ──────────────────────────────────────────
    push_log(
        state,
        job_id,
        "Running greentic-cards2pack...",
        LogKind::Progress,
    )
    .await;
    set_step(state, job_id, "Building cards...", 10).await;

    let mut cmd = Command::new("greentic-cards2pack");
    cmd.args(args)
        .stdout(std::process::Stdio::piped())
        .stderr(std::process::Stdio::piped());

    let mut child = match cmd.spawn() {
        Ok(c) => c,
        Err(e) => {
            set_failed(
                state,
                job_id,
                &format!("Failed to spawn greentic-cards2pack: {e}"),
            )
            .await;
            return;
        }
    };

    // Stream stderr line-by-line
    if let Some(stderr) = child.stderr.take() {
        let mut reader = BufReader::new(stderr).lines();
        while let Ok(Some(raw)) = reader.next_line().await {
            for sub in raw.split('\r').map(|s| s.trim()).filter(|s| !s.is_empty()) {
                let line = sub.to_string();
                let (kind, pct) = classify_line(&line);
                let step = summarize_step(&line);
                let mut jobs = state.wizard_jobs.lock().await;
                if let Some(job) = jobs.get_mut(job_id) {
                    // Deduplicate progress lines
                    if line.contains("Progress:")
                        && let Some(pos) = job.lines.iter().rposition(|l| {
                            l.text.contains("Progress:")
                                && l.text.split("Progress:").next()
                                    == line.split("Progress:").next()
                        })
                    {
                        job.lines.remove(pos);
                    }
                    job.lines.push(PackLogLine { text: line, kind });
                    if let Some(p) = pct {
                        job.progress = p;
                    }
                    if !step.is_empty() {
                        job.step = step;
                    }
                }
            }
        }
    }

    let success = child.wait().await.is_ok_and(|s| s.success());
    if !success {
        set_failed(state, job_id, "greentic-cards2pack failed").await;
        return;
    }

    // ── Step 2: Find .gtpack in dist/ ────────────────────────────────────────
    let dist = out_dir.join("dist");
    let pack_path = find_file_in_dir(&dist, "gtpack");
    let Some(pack_path) = pack_path else {
        set_failed(state, job_id, "No .gtpack file found in dist/").await;
        return;
    };

    // ── Step 3: Inject HTTP nodes if any ─────────────────────────────────────
    if !http_entries.is_empty() {
        let flow_path = out_dir.join("flows").join("main.ygtc");
        if let Ok(ygtc) = std::fs::read_to_string(&flow_path) {
            let injected = crate::orchestrate::http_inject::inject_http_nodes(&ygtc, http_entries);
            let _ = std::fs::write(&flow_path, injected);
            let pack_yaml = out_dir.join("pack.yaml");
            let _ = crate::orchestrate::http_inject::ensure_component_http_source(&pack_yaml);
        }
        push_log(
            state,
            job_id,
            &format!("Injected {} HTTP API node(s) into flow", http_entries.len()),
            LogKind::Done,
        )
        .await;
    }

    // ── Step 4: Build .gtbundle ───────────────────────────────────────────────
    set_step(state, job_id, "Building .gtbundle...", 70).await;
    push_log(state, job_id, "Building .gtbundle...", LogKind::Progress).await;

    let bundle_result = tokio::task::spawn_blocking({
        let pack_path = pack_path.clone();
        let pack_name = pack_name.to_string();
        let providers = providers.to_vec();
        move || deployer::build_bundle(&pack_path, &pack_name, Some(&providers))
    })
    .await;

    let bundle_result = match bundle_result {
        Ok(Ok(br)) => br,
        Ok(Err(e)) => {
            set_failed(state, job_id, &format!("Bundle build failed: {e}")).await;
            return;
        }
        Err(e) => {
            set_failed(state, job_id, &format!("Bundle build task panicked: {e}")).await;
            return;
        }
    };

    // ── Step 5: Mode-specific finalisation ───────────────────────────────────
    match mode {
        WizardMode::Deploy => {
            let cloud_name = cloud.unwrap_or("aws");
            set_step(state, job_id, "Deploying to cloud...", 85).await;
            {
                let mut jobs = state.wizard_jobs.lock().await;
                if let Some(job) = jobs.get_mut(job_id) {
                    job.status = WizardJobStatus::Deploying;
                }
            }

            let deploy_result = tokio::task::spawn_blocking({
                let bp = bundle_result.bundle_path.clone();
                let name = pack_name.to_string();
                let cloud_str = cloud_name.to_string();
                move || deployer::deploy_bundle(&bp, &name, &cloud_str)
            })
            .await;

            match deploy_result {
                Ok(Ok(dr)) => {
                    let mut jobs = state.wizard_jobs.lock().await;
                    if let Some(job) = jobs.get_mut(job_id) {
                        job.status = WizardJobStatus::Done;
                        job.progress = 100;
                        job.step = "Deployed!".to_string();
                        job.deploy_url = Some(dr.deploy_url.clone());
                        job.lines.push(PackLogLine {
                            text: format!("Deployed: {}", dr.deploy_url),
                            kind: LogKind::Done,
                        });
                    }
                }
                Ok(Err(e)) => {
                    set_failed(state, job_id, &format!("Deploy failed: {e}")).await;
                }
                Err(e) => {
                    set_failed(state, job_id, &format!("Deploy task panicked: {e}")).await;
                }
            }
        }

        WizardMode::Develop => {
            set_step(state, job_id, "Zipping bundle workspace...", 88).await;

            let zip_name = format!("{pack_name}-bundle.zip");
            let zip_path = std::env::temp_dir().join(&zip_name);

            let src = bundle_result.workspace_path.clone();
            let dst = zip_path.clone();
            let zip_err = tokio::task::spawn_blocking(move || zip_directory(&src, &dst)).await;

            match zip_err {
                Ok(Ok(())) => {
                    let dl = download_url(&zip_path);
                    let mut jobs = state.wizard_jobs.lock().await;
                    if let Some(job) = jobs.get_mut(job_id) {
                        job.status = WizardJobStatus::Done;
                        job.progress = 100;
                        job.step = "Ready to download!".to_string();
                        job.download_url = Some(dl.clone());
                        job.filename = Some(zip_name.clone());
                        job.lines.push(PackLogLine {
                            text: format!("Bundle workspace zipped: {zip_name}"),
                            kind: LogKind::Done,
                        });
                    }
                }
                Ok(Err(e)) => {
                    // Zip failed — fall back to offering the .gtbundle directly
                    let fname = bundle_result
                        .bundle_path
                        .file_name()
                        .map(|f| f.to_string_lossy().to_string())
                        .unwrap_or_else(|| format!("{pack_name}.gtbundle"));
                    let dl = download_url(&bundle_result.bundle_path);
                    let mut jobs = state.wizard_jobs.lock().await;
                    if let Some(job) = jobs.get_mut(job_id) {
                        job.status = WizardJobStatus::Done;
                        job.progress = 100;
                        job.step = "Ready (zip failed, bundle available)".to_string();
                        job.download_url = Some(dl);
                        job.filename = Some(fname);
                        job.lines.push(PackLogLine {
                            text: format!("Zip failed ({e}), serving .gtbundle instead"),
                            kind: LogKind::Warning,
                        });
                    }
                }
                Err(e) => {
                    set_failed(state, job_id, &format!("Zip task panicked: {e}")).await;
                }
            }
        }
    }
}

// ── Async state helpers ───────────────────────────────────────────────────────

async fn set_failed(state: &Arc<AppState>, job_id: &str, msg: &str) {
    let mut jobs = state.wizard_jobs.lock().await;
    if let Some(job) = jobs.get_mut(job_id) {
        job.status = WizardJobStatus::Failed;
        job.error = Some(msg.to_string());
        job.lines.push(PackLogLine {
            text: msg.to_string(),
            kind: LogKind::Error,
        });
    }
}

async fn push_log(state: &Arc<AppState>, job_id: &str, msg: &str, kind: LogKind) {
    let mut jobs = state.wizard_jobs.lock().await;
    if let Some(job) = jobs.get_mut(job_id) {
        job.lines.push(PackLogLine {
            text: msg.to_string(),
            kind,
        });
    }
}

async fn set_step(state: &Arc<AppState>, job_id: &str, step: &str, progress: u8) {
    let mut jobs = state.wizard_jobs.lock().await;
    if let Some(job) = jobs.get_mut(job_id) {
        job.step = step.to_string();
        job.progress = progress;
    }
}

// ── Log classification ────────────────────────────────────────────────────────

pub fn classify_line(line: &str) -> (LogKind, Option<u8>) {
    if line.contains("Progress:") {
        let pct = line.split("Progress:").nth(1).and_then(|s| {
            let parts: Vec<&str> = s.trim().split('/').collect();
            if parts.len() == 2 {
                let cur: f32 = parts[0].trim().parse().ok()?;
                let total: f32 = parts[1].trim().parse().ok()?;
                Some((15.0 + (cur / total) * 45.0) as u8)
            } else {
                None
            }
        });
        (LogKind::Progress, pct)
    } else if line.contains("error") || line.contains("Error") || line.starts_with("ERR") {
        (LogKind::Error, None)
    } else if line.contains("warning") || line.contains("Warning") {
        (LogKind::Warning, None)
    } else if line.contains("wrote") || line.contains("Pack:") || line.contains("OK ") {
        (LogKind::Done, Some(65))
    } else if line.contains("[flow]") {
        (LogKind::Progress, Some(25))
    } else if line.contains("pack.yaml") {
        (LogKind::Progress, Some(45))
    } else if line.contains("Running:") {
        (LogKind::Progress, Some(60))
    } else {
        (LogKind::Info, None)
    }
}

pub fn summarize_step(line: &str) -> String {
    if line.contains("created pack at") {
        "Creating workspace...".to_string()
    } else if line.contains("[flow]") {
        line.trim().to_string()
    } else if line.contains("pack.yaml updated") {
        "Updating pack manifest...".to_string()
    } else if line.starts_with("OK ") || line.contains("valid") {
        "Validating flows...".to_string()
    } else if line.contains("wrote") && line.contains(".gtpack") {
        "Writing .gtpack archive...".to_string()
    } else if line.contains("Running:") {
        "Running greentic-pack build...".to_string()
    } else if line.contains("Pack:") && !line.contains("pack.yaml") {
        "Pack summary...".to_string()
    } else {
        String::new()
    }
}