car-ffi-common 0.24.1

Shared logic for FFI bindings (NAPI, PyO3) — JSON wrappers for verify, multi-agent, scheduler
//! JSON wrappers for the agent registry capability surface.
//!
//! Each FFI binding (NAPI, PyO3, server) calls these from its own
//! free functions / RPC methods. The wrappers handle path resolution
//! (default to `~/.car/registry/` when the caller passes `None`),
//! JSON parse/serialize, and error stringification.
//!
//! All operations are synchronous filesystem calls — no daemon, no
//! shared state beyond the directory itself.
//!
//! ## Wire shapes
//!
//! `registerAgent(entryJson, registryPath?)`:
//! ```jsonc
//! {
//!   "name": "trader-paper",
//!   "dashboard_url": "http://127.0.0.1:8731",
//!   "status": "running",         // running|idle|errored|stopping
//!   "display_name": "Trader",     // optional
//!   "port": 8731,                 // optional u16
//!   "pid": 12345                  // optional u32
//! }
//! ```
//! Returns: `null` on success.
//!
//! `agentHeartbeat(name, registryPath?)`:
//! Returns: `{"refreshed": true}` if the agent existed, `false` if
//! the caller should re-register.
//!
//! `listAgents(registryPath?)`:
//! Returns: a JSON array of `AgentEntry` objects.
//!
//! `reapStaleAgents(maxAgeSecs, registryPath?)`:
//! Returns: a JSON array of names that were reaped.

use car_registry::{AgentEntry, AgentRegistry};
use std::path::PathBuf;

fn open(registry_path: Option<PathBuf>) -> Result<AgentRegistry, String> {
    match registry_path {
        Some(p) => AgentRegistry::open(p).map_err(|e| e.to_string()),
        None => AgentRegistry::user_default().map_err(|e| e.to_string()),
    }
}

/// Register or replace an agent's entry. `entry_json` is an
/// `AgentEntry` serialised as JSON. Missing optional fields take
/// their `Default::default()` values; missing required fields
/// (`name`, `dashboard_url`) yield a parse error.
pub fn register_agent(entry_json: &str, registry_path: Option<PathBuf>) -> Result<String, String> {
    let entry: AgentEntry =
        serde_json::from_str(entry_json).map_err(|e| format!("invalid AgentEntry JSON: {e}"))?;
    let reg = open(registry_path)?;
    reg.register(&entry).map_err(|e| e.to_string())?;
    Ok("null".to_string())
}

/// Bump `last_heartbeat_at` for an existing entry. Returns
/// `{"refreshed": true|false}` — `false` means the named agent
/// wasn't registered (caller should re-register).
pub fn agent_heartbeat(name: &str, registry_path: Option<PathBuf>) -> Result<String, String> {
    let reg = open(registry_path)?;
    let refreshed = reg.heartbeat(name).map_err(|e| e.to_string())?;
    Ok(serde_json::json!({"refreshed": refreshed}).to_string())
}

/// Remove an agent's entry. Idempotent — succeeds whether or not
/// the entry exists.
pub fn unregister_agent(name: &str, registry_path: Option<PathBuf>) -> Result<String, String> {
    let reg = open(registry_path)?;
    reg.unregister(name).map_err(|e| e.to_string())?;
    Ok("null".to_string())
}

/// List all registered agents, sorted by `name`.
pub fn list_agents(registry_path: Option<PathBuf>) -> Result<String, String> {
    let reg = open(registry_path)?;
    let entries = reg.list().map_err(|e| e.to_string())?;
    serde_json::to_string(&entries).map_err(|e| e.to_string())
}

/// Reap entries whose last heartbeat is older than `max_age_secs`.
/// Returns the names of reaped entries as a JSON array.
pub fn reap_stale_agents(
    max_age_secs: u64,
    registry_path: Option<PathBuf>,
) -> Result<String, String> {
    let reg = open(registry_path)?;
    let reaped = reg.reap_stale(max_age_secs).map_err(|e| e.to_string())?;
    serde_json::to_string(&reaped).map_err(|e| e.to_string())
}