car-ffi-common 0.17.0

Shared logic for FFI bindings (NAPI, PyO3) — JSON wrappers for verify, multi-agent, scheduler
Documentation
//! In-process external-agent detection for FFI bindings.
//!
//! Wraps [`car_external_agents::detect`] behind a process-singleton
//! cache so NAPI/PyO3 consumers can ask for the latest snapshot
//! without re-probing every binary on each call. Mirrors the
//! `supervisor` module's wire-shape contract: every function takes /
//! returns JSON, identical to the daemon's WS `agents.*` namespace.
//!
//! See `docs/proposals/external-agent-detection.md` and
//! `car-rs/crates/car-external-agents/README.md`.
//!
//! ## Wire shapes
//!
//! - `list()` → `[ExternalAgentSpec]` — cached snapshot. First call
//!   triggers a detection pass; subsequent calls return the cache.
//! - `detect()` → `[ExternalAgentSpec]` — force re-detection,
//!   update cache, return the new snapshot.

use std::sync::OnceLock;
use tokio::sync::Mutex;

use car_external_agents::ExternalAgentSpec;

fn cache() -> &'static Mutex<Option<Vec<ExternalAgentSpec>>> {
    static CACHE: OnceLock<Mutex<Option<Vec<ExternalAgentSpec>>>> = OnceLock::new();
    CACHE.get_or_init(|| Mutex::new(None))
}

/// Cached snapshot. Populates lazily on first call. When
/// `include_health` is true, each spec's `health` field is also
/// populated via the tool's auth-status command — ground truth,
/// slower (one subprocess spawn per detected adapter).
pub async fn list(include_health: bool) -> Result<String, String> {
    let mut guard = cache().lock().await;
    if guard.is_none() {
        *guard = Some(car_external_agents::detect().await);
    }
    let mut specs = guard.as_ref().expect("populated above").clone();
    drop(guard);
    if include_health {
        let healths = car_external_agents::health_all(&specs, false).await;
        let by_id: std::collections::HashMap<&str, &car_external_agents::ExternalAgentHealth> =
            healths.iter().map(|h| (h.id.as_str(), h)).collect();
        for spec in specs.iter_mut() {
            if let Some(h) = by_id.get(spec.id.as_str()) {
                spec.health = Some((*h).clone());
            }
        }
    }
    serde_json::to_string(&specs).map_err(|e| e.to_string())
}

/// Force re-detection; updates the cache and returns the new snapshot.
/// When `include_health` is true, also runs ground-truth health
/// checks (with `force = true` so they bypass the TTL cache).
pub async fn detect(include_health: bool) -> Result<String, String> {
    let specs = if include_health {
        car_external_agents::detect_with_health(true).await
    } else {
        car_external_agents::detect().await
    };
    let mut guard = cache().lock().await;
    // Cache the spec without health so subsequent `list()` calls
    // without `include_health` aren't burdened by stale health data.
    *guard = Some(
        specs
            .iter()
            .map(|s| {
                let mut s = s.clone();
                s.health = None;
                s
            })
            .collect(),
    );
    serde_json::to_string(&specs).map_err(|e| e.to_string())
}

/// Run health checks for every detected external agent. Returns
/// `[ExternalAgentHealth]` JSON. Detection runs first so the
/// health-check list is in lockstep with `list()`'s presence
/// snapshot. The `force` flag bypasses the per-tool 30s health-check
/// TTL cache.
pub async fn health(force: bool) -> Result<String, String> {
    let specs = car_external_agents::detect().await;
    let healths = car_external_agents::health_all(&specs, force).await;
    serde_json::to_string(&healths).map_err(|e| e.to_string())
}

/// Run a health check for one agent id. Returns the
/// `ExternalAgentHealth` JSON, or an error when the id isn't a
/// known adapter or its binary isn't installed.
pub async fn health_one(id: &str, force: bool) -> Result<String, String> {
    let result = car_external_agents::health_one(id, force)
        .await
        .ok_or_else(|| format!("no detected external agent with id `{id}`"))?;
    serde_json::to_string(&result).map_err(|e| e.to_string())
}

/// Per-task invocation. `params_json` carries the full request
/// payload as JSON (matches the WS shape); `id` is the adapter
/// to dispatch to. Returns `InvokeResult` JSON.
///
/// Phase 2 stage 3 ships with Claude Code as the only fully
/// supported adapter. Codex and Gemini land in subsequent PRs.
pub async fn invoke(id: &str, task: &str, options_json: &str) -> Result<String, String> {
    let opts: car_external_agents::InvokeOptions = if options_json.trim().is_empty() {
        car_external_agents::InvokeOptions::default()
    } else {
        serde_json::from_str(options_json).map_err(|e| format!("invalid options: {e}"))?
    };
    let result = car_external_agents::invoke(id, task, opts)
        .await
        .map_err(|e| e.to_string())?;
    serde_json::to_string(&result).map_err(|e| e.to_string())
}