meerkat-rest 0.8.2

REST API server for Meerkat
Documentation
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]

use serde_json::json;
use std::path::PathBuf;
use std::process::Stdio;
use tempfile::TempDir;
use tokio::io::{AsyncReadExt, BufReader};
use tokio::process::Command;
use tokio::time::{Duration, Instant, sleep, timeout};

fn rest_binary_path() -> Option<PathBuf> {
    if let Some(path) = std::env::var_os("CARGO_BIN_EXE_rkat-rest") {
        let path = PathBuf::from(path);
        if path.exists() {
            return Some(path);
        }
    }

    if let Some(target_dir) = std::env::var_os("CARGO_TARGET_DIR") {
        let target_dir = PathBuf::from(target_dir);
        let candidates = [
            target_dir.join("debug/rkat-rest"),
            target_dir.join("release/rkat-rest"),
        ];
        if let Some(candidate) = candidates.into_iter().find(|candidate| candidate.exists()) {
            return Some(candidate);
        }
    }

    let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
    let workspace_root = manifest_dir.parent()?;
    let debug = workspace_root.join("target/debug/rkat-rest");
    if debug.exists() {
        return Some(debug);
    }
    let release = workspace_root.join("target/release/rkat-rest");
    if release.exists() {
        return Some(release);
    }
    None
}

async fn wait_for_rest_server(port: u16) -> Result<(), Box<dyn std::error::Error>> {
    let client = reqwest::Client::new();
    let deadline = Instant::now() + Duration::from_secs(20);
    while Instant::now() < deadline {
        if let Ok(response) = client
            .get(format!("http://127.0.0.1:{port}/health"))
            .send()
            .await
            && response.status().is_success()
        {
            return Ok(());
        }
        sleep(Duration::from_millis(100)).await;
    }
    Err(format!("rkat-rest did not become healthy on port {port}").into())
}

fn allocate_port() -> u16 {
    std::net::TcpListener::bind("127.0.0.1:0")
        .expect("bind ephemeral port")
        .local_addr()
        .expect("local addr")
        .port()
}

#[tokio::test]
#[ignore = "lane:e2e-build"]
async fn http_e2e_create_and_continue_with_test_client() -> Result<(), Box<dyn std::error::Error>> {
    let Some(binary) = rest_binary_path() else {
        eprintln!("Skipping: missing rkat-rest binary");
        return Ok(());
    };

    let realm_id = "rest-binary-e2e";
    let temp = TempDir::new()?;
    let project_dir = temp.path().join("project");
    let state_root = temp.path().join("state");
    tokio::fs::create_dir_all(project_dir.join("data")).await?;
    let realm_root = state_root.join(realm_id);
    tokio::fs::create_dir_all(&realm_root).await?;
    let mut child_and_stderr = None;
    let mut port = 0;
    for _attempt in 0..5 {
        port = allocate_port();
        tokio::fs::write(
            realm_root.join("config.toml"),
            format!(
                "[agent]\nmodel = \"claude-sonnet-4-5\"\nmax_tokens_per_turn = 128\n[rest]\nhost = \"127.0.0.1\"\nport = {port}\n"
            ),
        )
        .await?;

        let mut child = Command::new(&binary)
            .current_dir(&project_dir)
            .env("HOME", &project_dir)
            .env("XDG_DATA_HOME", project_dir.join("data"))
            .env("RKAT_TEST_CLIENT", "1")
            .args([
                "--realm",
                realm_id,
                "--state-root",
                state_root.to_str().ok_or("invalid state root")?,
                "--context-root",
                project_dir.to_str().ok_or("invalid project dir")?,
            ])
            .stdin(Stdio::null())
            .stdout(Stdio::null())
            .stderr(Stdio::piped())
            .spawn()?;

        let stderr = child.stderr.take().ok_or("missing stderr")?;
        let stderr_task = tokio::spawn(async move {
            let mut reader = BufReader::new(stderr);
            let mut buffer = Vec::new();
            let _ = reader.read_to_end(&mut buffer).await;
            String::from_utf8_lossy(&buffer).to_string()
        });

        match wait_for_rest_server(port).await {
            Ok(()) => {
                child_and_stderr = Some((child, stderr_task));
                break;
            }
            Err(err) => {
                let _ = child.kill().await;
                let stderr = stderr_task.await.unwrap_or_else(|_| String::new());
                if stderr.contains("Address already in use") {
                    continue;
                }
                return Err(format!("{err}; stderr:\n{stderr}").into());
            }
        }
    }

    let Some((mut child, stderr_task)) = child_and_stderr else {
        return Err("failed to start rkat-rest after retrying address allocation".into());
    };

    let client = reqwest::Client::new();
    let created = timeout(
        Duration::from_secs(30),
        client
            .post(format!("http://127.0.0.1:{port}/sessions"))
            .json(&json!({
                "prompt": "Remember REST_BINARY_42 and reply with ok.",
                "model": "claude-sonnet-4-5",
                "max_tokens": 64
            }))
            .send(),
    )
    .await??;
    assert!(created.status().is_success(), "create failed: {created:?}");
    let created_json: serde_json::Value = created.json().await?;
    let session_id = created_json["session_id"]
        .as_str()
        .ok_or("missing session_id")?
        .to_string();

    let continued = timeout(
        Duration::from_secs(30),
        client
            .post(format!(
                "http://127.0.0.1:{port}/sessions/{session_id}/messages"
            ))
            .json(&json!({
                "session_id": session_id,
                "prompt": "Continue the same session and reply with ok."
            }))
            .send(),
    )
    .await??;
    assert!(
        continued.status().is_success(),
        "continue failed: {continued:?}"
    );
    let continued_json: serde_json::Value = continued.json().await?;
    assert_eq!(
        continued_json["session_id"].as_str(),
        Some(session_id.as_str())
    );

    let _ = child.kill().await;
    let _ = stderr_task.await;
    Ok(())
}