omk 0.5.0

A Rust runtime for Kimi CLI. Turns prompts into proof-backed engineering runs with gates, worktrees, and replay.
Documentation
use axum::{http::StatusCode, response::Response, Json};
use serde_json::Value;

pub(super) async fn teams_handler() -> Json<Value> {
    let state_dir = crate::runtime::config::state_dir().join("team");
    let mut teams = Vec::new();

    if let Ok(mut entries) = tokio::fs::read_dir(&state_dir).await {
        while let Ok(Some(entry)) = entries.next_entry().await {
            let team_state = entry.path().join("team-state.json");
            if let Ok(content) = tokio::fs::read_to_string(&team_state).await {
                if let Ok(value) = serde_json::from_str::<Value>(&content) {
                    teams.push(value);
                }
            }
        }
    }

    Json(serde_json::json!({ "teams": teams }))
}

pub(super) async fn autopilots_handler() -> Json<Value> {
    let state_dir = crate::runtime::config::state_dir().join("autopilot");
    let mut autopilots = Vec::new();

    if let Ok(mut entries) = tokio::fs::read_dir(&state_dir).await {
        while let Ok(Some(entry)) = entries.next_entry().await {
            let ap_state = entry.path().join("autopilot-state.json");
            if let Ok(content) = tokio::fs::read_to_string(&ap_state).await {
                if let Ok(value) = serde_json::from_str::<Value>(&content) {
                    autopilots.push(value);
                }
            }
        }
    }

    Json(serde_json::json!({ "autopilots": autopilots }))
}

pub(super) async fn ralphs_handler() -> Json<Value> {
    let state_dir = crate::runtime::config::state_dir().join("ralph");
    let mut ralphs = Vec::new();

    if let Ok(mut entries) = tokio::fs::read_dir(&state_dir).await {
        while let Ok(Some(entry)) = entries.next_entry().await {
            let ralph_state = entry.path().join("ralph-state.json");
            if let Ok(content) = tokio::fs::read_to_string(&ralph_state).await {
                if let Ok(value) = serde_json::from_str::<Value>(&content) {
                    ralphs.push(value);
                }
            }
        }
    }

    Json(serde_json::json!({ "ralphs": ralphs }))
}

pub(super) async fn metrics_handler() -> Json<Value> {
    let metrics_path = crate::runtime::config::state_dir().join("metrics.json");
    let metrics = if let Ok(content) = tokio::fs::read_to_string(&metrics_path).await {
        serde_json::from_str(&content)
            .map(normalize_metrics_value)
            .unwrap_or_else(|_| serde_json::json!(null))
    } else {
        serde_json::json!(null)
    };

    Json(serde_json::json!({ "metrics": metrics }))
}

pub(super) async fn health_handler() -> Json<Value> {
    let mut checks = serde_json::json!({});
    let mut healthy = true;

    // Check kimi
    let kimi_ok = match tokio::time::timeout(
        std::time::Duration::from_secs(30),
        tokio::process::Command::new("kimi")
            .arg("--version")
            .output(),
    )
    .await
    {
        Ok(Ok(o)) => o.status.success(),
        _ => false,
    };
    checks["kimi"] = serde_json::json!({"status": if kimi_ok { "ok" } else { "error" } });
    if !kimi_ok {
        healthy = false;
    }

    // Check disk space
    let state_dir = crate::runtime::config::state_dir();
    let disk_ok = check_disk_space(&state_dir).await;
    checks["disk"] = serde_json::json!({
        "status": if disk_ok { "ok" } else { "warning" },
        "path": state_dir.to_string_lossy().to_string(),
    });

    Json(serde_json::json!({
        "status": if healthy { "ok" } else { "degraded" },
        "version": env!("CARGO_PKG_VERSION"),
        "checks": checks,
    }))
}

pub(super) async fn check_disk_space(path: &std::path::Path) -> bool {
    #[cfg(unix)]
    {
        if tokio::fs::metadata(path).await.is_ok() {
            // This is a simplified check; in production you'd use statvfs
            return true;
        }
    }
    true
}

pub(super) async fn prometheus_metrics_handler() -> std::result::Result<Response<String>, StatusCode>
{
    let metrics_path = crate::runtime::config::state_dir().join("metrics.json");
    let mut output = String::new();

    output.push_str("# HELP omk_info OMK version info\n");
    output.push_str("# TYPE omk_info gauge\n");
    output.push_str(&format!(
        "omk_info{{version=\"{}\"}} 1\n",
        env!("CARGO_PKG_VERSION")
    ));

    if let Ok(content) = tokio::fs::read_to_string(&metrics_path).await {
        if let Ok(metrics) = serde_json::from_str::<serde_json::Value>(&content) {
            if let Some(team_runs) = metric_u64(&metrics, "total_team_runs", Some("total_spawns")) {
                output.push_str("\n# HELP omk_total_team_runs_total Total team run starts\n");
                output.push_str("# TYPE omk_total_team_runs_total counter\n");
                output.push_str(&format!("omk_total_team_runs_total {}\n", team_runs));
                output.push_str(
                    "\n# HELP omk_total_spawns_total Legacy alias for omk_total_team_runs_total\n",
                );
                output.push_str("# TYPE omk_total_spawns_total counter\n");
                output.push_str(&format!("omk_total_spawns_total {}\n", team_runs));
            }
            if let Some(shutdowns) = metrics["total_shutdowns"].as_u64() {
                output.push_str("\n# HELP omk_total_shutdowns_total Total team shutdowns\n");
                output.push_str("# TYPE omk_total_shutdowns_total counter\n");
                output.push_str(&format!("omk_total_shutdowns_total {}\n", shutdowns));
            }
            if let Some(tasks) = metrics["total_tasks_created"].as_u64() {
                output.push_str("\n# HELP omk_total_tasks_created_total Total tasks created\n");
                output.push_str("# TYPE omk_total_tasks_created_total counter\n");
                output.push_str(&format!("omk_total_tasks_created_total {}\n", tasks));
            }
            if let Some(ask) = metrics["total_ask_calls"].as_u64() {
                output.push_str("\n# HELP omk_total_ask_calls_total Total ask calls\n");
                output.push_str("# TYPE omk_total_ask_calls_total counter\n");
                output.push_str(&format!("omk_total_ask_calls_total {}\n", ask));
            }
        }
    }

    axum::response::Response::builder()
        .header("Content-Type", "text/plain; version=0.0.4")
        .body(output)
        .map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)
}

fn metric_u64(metrics: &Value, primary: &str, legacy: Option<&str>) -> Option<u64> {
    metrics
        .get(primary)
        .and_then(Value::as_u64)
        .or_else(|| legacy.and_then(|key| metrics.get(key).and_then(Value::as_u64)))
}

fn normalize_metrics_value(mut metrics: Value) -> Value {
    let Some(team_runs) = metric_u64(&metrics, "total_team_runs", Some("total_spawns")) else {
        return metrics;
    };
    if let Some(obj) = metrics.as_object_mut() {
        let value = Value::Number(team_runs.into());
        obj.entry("total_team_runs")
            .or_insert_with(|| value.clone());
        obj.entry("total_spawns").or_insert(value);
    }
    metrics
}