clawdb-server 0.1.2

gRPC and HTTP server for the ClawDB cognitive database.
Documentation
use std::{
    net::{IpAddr, Ipv4Addr, SocketAddr},
    time::Duration,
};

use clawdb::ClawDBConfig;
use clawdb_server::{
    build_state,
    grpc::service::proto::{
        claw_db_service_client::ClawDbServiceClient, HealthRequest, RememberRequest, SearchRequest,
    },
    spawn_servers, ServerOptions,
};
use tempfile::tempdir;
use uuid::Uuid;

#[tokio::test]
async fn server_exposes_http_grpc_and_metrics() -> anyhow::Result<()> {
    let temp = tempdir()?;
    let mut config = ClawDBConfig::default_for_dir(temp.path());
    config.guard.jwt_secret = "test-secret".to_string();
    config.vector.enabled = false;
    config.reflect.base_url = None;
    config.reflect.api_key = None;
    config.sync.hub_url = None;

    let state = build_state(config).await?;
    let session = state
        .db
        .session(
            Uuid::new_v4(),
            "agent",
            vec!["memory:write".to_string(), "memory:read".to_string()],
        )
        .await?;
    let servers = spawn_servers(
        state,
        ServerOptions {
            grpc_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0),
            http_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0),
            metrics_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0),
        },
    )
    .await?;

    let http_base = format!("http://{}", servers.addresses.http);
    let metrics_url = format!("http://{}/metrics", servers.addresses.metrics);
    let grpc_url = format!("http://{}", servers.addresses.grpc);

    let client = reqwest::Client::new();
    let health = get_with_retry(&client, &format!("{http_base}/v1/health")).await?;
    assert!(health.status().is_success());

    let ready = get_with_retry(&client, &format!("{http_base}/v1/ready")).await?;
    assert!(ready.status().is_success());

    let unauthorized = client
        .post(format!("{http_base}/v1/memories"))
        .json(&serde_json::json!({ "content": "blocked" }))
        .send()
        .await?;
    assert_eq!(unauthorized.status(), reqwest::StatusCode::UNAUTHORIZED);

    let mut grpc = connect_grpc_with_retry(&grpc_url).await?;
    let grpc_health = grpc.health(tonic::Request::new(HealthRequest {})).await?;
    assert!(grpc_health.get_ref().ok);
    assert!(!grpc_health.get_ref().request_id.is_empty());

    let unauth = grpc
        .remember(tonic::Request::new(RememberRequest {
            content: "unauthorized".to_string(),
        }))
        .await
        .expect_err("missing token should be rejected");
    assert_eq!(unauth.code(), tonic::Code::Unauthenticated);

    let mut remember_req = tonic::Request::new(RememberRequest {
        content: "grpc memory".to_string(),
    });
    remember_req.metadata_mut().insert(
        "x-claw-session",
        tonic::metadata::MetadataValue::try_from(session.token.as_str())?,
    );
    let remember_resp = grpc.remember(remember_req).await?;
    assert!(!remember_resp.get_ref().memory_id.is_empty());

    let mut search_req = tonic::Request::new(SearchRequest {
        query: "grpc memory".to_string(),
        top_k: 5,
        semantic: false,
        filter_json: String::new(),
    });
    search_req.metadata_mut().insert(
        "x-claw-session",
        tonic::metadata::MetadataValue::try_from(session.token.as_str())?,
    );
    let search_resp = grpc.search(search_req).await?;
    assert!(!search_resp.get_ref().hits.is_empty());

    let metrics = get_with_retry(&client, &metrics_url).await?;
    assert!(metrics.status().is_success());
    let body = metrics.text().await?;
    assert!(body.contains("clawdb_http_requests_total"));
    assert!(body.contains("clawdb_grpc_requests_total"));

    servers.shutdown(Duration::from_secs(5)).await?;
    Ok(())
}

async fn get_with_retry(client: &reqwest::Client, url: &str) -> anyhow::Result<reqwest::Response> {
    let mut last_error = None;
    for _ in 0..20 {
        match client.get(url).send().await {
            Ok(response) => return Ok(response),
            Err(error) => last_error = Some(error),
        }
        tokio::time::sleep(Duration::from_millis(50)).await;
    }
    Err(last_error
        .expect("retry loop should capture an error")
        .into())
}

async fn connect_grpc_with_retry(
    url: &str,
) -> anyhow::Result<ClawDbServiceClient<tonic::transport::Channel>> {
    let mut last_error = None;
    for _ in 0..20 {
        match ClawDbServiceClient::connect(url.to_string()).await {
            Ok(client) => return Ok(client),
            Err(error) => last_error = Some(error),
        }
        tokio::time::sleep(Duration::from_millis(50)).await;
    }
    Err(last_error
        .expect("retry loop should capture an error")
        .into())
}