car-ffi-common 0.47.0

Shared logic for FFI bindings (NAPI, PyO3) — JSON wrappers for verify, multi-agent, scheduler
//! Proxies for the daemon's `coder.*` namespace (built-in coding agent).
//!
//! Unlike the in-process `supervisor`/`external_agents` modules, coder
//! sessions live **in the daemon** — they must be visible to CarHost and
//! survive the FFI caller's process — so every function here is a thin
//! [`DaemonClient`] call with the exact WS wire shapes. Streaming
//! (`coder.subscribe` / `coder.event`) is WebSocket-only; FFI callers that
//! want live events connect to the daemon's WS directly (same contract as
//! `infer_stream`).

use serde_json::{json, Value};

use crate::proxy::DaemonClient;

fn to_string(v: Value) -> Result<String, String> {
    serde_json::to_string(&v).map_err(|e| e.to_string())
}

/// `coder.start` — provision a worktree, derive the outcome contract.
/// `engine` is `auto | native | external[:agent_id]` (None = auto).
/// Everything optional about a coder session, in one place.
///
/// Replaced five consecutive positional `Option`s — three of them `Option<u32>`
/// — which callers could silently mis-order. Every field is independently
/// omittable and each falls back to the daemon's `~/.car/coder.toml`.
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
pub struct CoderStartOptions {
    /// `native`, `external:<cli>`, `foreman:<cli>`, or `auto`.
    pub engine: Option<String>,
    /// Contract-evaluation rounds before the native loop gives up.
    pub max_iterations: Option<u32>,
    /// Per-session backbone pin, overriding `coder.toml`. Reaches whichever
    /// engine runs, which is what makes a paired A/B's same-backbone claim
    /// enforceable rather than assumed.
    pub model: Option<String>,
    /// External-engine hypothesis budget: how many fresh repair invocations
    /// follow a red first pass. Recurrence escalation needs >= 2 to reach the
    /// model at all (round 1 establishes a failure signature, round 2 is the
    /// first that can repeat it, round 3 the first that can be told).
    pub repair_invokes: Option<u32>,
    /// External-engine availability budget: re-invocations after the CLI
    /// process itself died mid-run. Deliberately separate from
    /// `repair_invokes` — one buys a hypothesis, the other buys a retry, and
    /// sharing a counter lets one flaky timeout eat a replan the coder needed.
    pub transient_retries: Option<u32>,
    /// A `coder.discuss` conversation this run was distilled from. Its agreed
    /// constraints are folded into contract derivation and the session records
    /// the provenance; an unknown id is a hard error rather than a silently
    /// ungrounded run.
    pub discussion_id: Option<String>,
}

pub async fn start(
    client: &DaemonClient,
    repo: &str,
    intent: &str,
    opts: &CoderStartOptions,
) -> Result<String, String> {
    let mut params = json!({ "repo": repo, "intent": intent });
    // A blank string is "unset", not "pin the empty engine/model".
    if let Some(engine) = opts
        .engine
        .as_deref()
        .map(str::trim)
        .filter(|s| !s.is_empty())
    {
        params["engine"] = json!(engine);
    }
    if let Some(n) = opts.max_iterations {
        params["max_iterations"] = json!(n);
    }
    if let Some(model) = opts
        .model
        .as_deref()
        .map(str::trim)
        .filter(|m| !m.is_empty())
    {
        params["model"] = json!(model);
    }
    if let Some(n) = opts.repair_invokes {
        params["repair_invokes"] = json!(n);
    }
    if let Some(n) = opts.transient_retries {
        params["transient_retries"] = json!(n);
    }
    if let Some(id) = opts
        .discussion_id
        .as_deref()
        .map(str::trim)
        .filter(|s| !s.is_empty())
    {
        params["discussion_id"] = json!(id);
    }
    client.call("coder.start", params).await.and_then(to_string)
}

/// `coder.confirm_contract` — accept (or replace with `contract_json`) the
/// proposed contract and start the work loop.
pub async fn confirm_contract(
    client: &DaemonClient,
    session_id: &str,
    contract_json: Option<&str>,
) -> Result<String, String> {
    let mut params = json!({ "session_id": session_id });
    if let Some(contract) = contract_json {
        params["contract"] =
            serde_json::from_str(contract).map_err(|e| format!("invalid contract JSON: {e}"))?;
    }
    client
        .call("coder.confirm_contract", params)
        .await
        .and_then(to_string)
}

/// `coder.list` — all sessions (live + persisted), newest first.
pub async fn list(client: &DaemonClient) -> Result<String, String> {
    client
        .call("coder.list", json!({}))
        .await
        .and_then(to_string)
}

/// `coder.get` — full session detail.
pub async fn get(client: &DaemonClient, session_id: &str) -> Result<String, String> {
    client
        .call("coder.get", json!({ "session_id": session_id }))
        .await
        .and_then(to_string)
}

/// `coder.respond` — answer a `user_input_requested` event (reserved).
pub async fn respond(
    client: &DaemonClient,
    session_id: &str,
    text: &str,
) -> Result<String, String> {
    client
        .call(
            "coder.respond",
            json!({ "session_id": session_id, "text": text }),
        )
        .await
        .and_then(to_string)
}

/// `coder.approve_merge` — approve publishes the `car/coder/<id>` branch;
/// deny abandons the session.
pub async fn approve_merge(
    client: &DaemonClient,
    session_id: &str,
    approve: bool,
) -> Result<String, String> {
    client
        .call(
            "coder.approve_merge",
            json!({ "session_id": session_id, "approve": approve }),
        )
        .await
        .and_then(to_string)
}

/// `coder.cancel` — stop the loop, abandon the session, clean the worktree.
///
/// An already-terminal session succeeds too, keeping the same `state` key and
/// reporting through additive `already_terminal` / `message` fields. Callers
/// that cancel unconditionally on shutdown depend on that.
pub async fn cancel(client: &DaemonClient, session_id: &str) -> Result<String, String> {
    client
        .call("coder.cancel", json!({ "session_id": session_id }))
        .await
        .and_then(to_string)
}

/// `coder.watch` — the current session list **and** registration for
/// `coder.session_changed` on this connection, atomically.
///
/// The registration half is WebSocket-only by nature: the notifications land on
/// the daemon socket this client owns. An FFI caller that only wants the list
/// can treat this exactly like `list` with the additional session-summary keys
/// (`needs_you`, `failure_kind`, `worktree`, `next_seq`, …); one that wants the
/// changes connects to the daemon's WS directly, same contract as
/// `coder.subscribe`.
///
/// `renew: Some(true)` sends the LEASE-RENEWAL form instead: it re-registers
/// idempotently and answers `{ was_registered: bool }` — `true` if a live
/// registration was already there, `false` if this call had to create one (so
/// the caller was shed and should take a full snapshot). It builds no summaries
/// at all, which is what makes it safe to call on a short timer: the default
/// form does a whole-history disk scan. Anything else (`None` / `Some(false)`)
/// is the default form, byte-identical to what it has always sent.
pub async fn watch(client: &DaemonClient, renew: Option<bool>) -> Result<String, String> {
    let params = if renew == Some(true) {
        json!({ "renew": true })
    } else {
        json!({})
    };
    client.call("coder.watch", params).await.and_then(to_string)
}

/// `coder.unwatch` — stop receiving `coder.session_changed` on this connection.
pub async fn unwatch(client: &DaemonClient) -> Result<String, String> {
    client
        .call("coder.unwatch", json!({}))
        .await
        .and_then(to_string)
}

/// `coder.revise_contract` — redraft a PROPOSED contract from a plain-English
/// request ("also verify the Windows path").
///
/// Returns `{state, revised, contract, baseline, baseline_gates_nothing,
/// message}`. `revised: false` means the request could not be honored and
/// `contract` is the previous draft, byte-identical — never assume a revision
/// landed without reading the flag.
pub async fn revise_contract(
    client: &DaemonClient,
    session_id: &str,
    request: &str,
) -> Result<String, String> {
    client
        .call(
            "coder.revise_contract",
            json!({ "session_id": session_id, "request": request }),
        )
        .await
        .and_then(to_string)
}

/// `coder.discuss.start` — open a repo-grounded, **read-only** conversation.
/// It can never write in `repo`; `coder.start` is what performs work.
pub async fn discuss_start(client: &DaemonClient, repo: &str) -> Result<String, String> {
    client
        .call("coder.discuss.start", json!({ "repo": repo }))
        .await
        .and_then(to_string)
}

/// `coder.discuss.send` — one operator message. Returns `{ok, seq}` where `seq`
/// is the first event this turn emits; the reply streams as
/// `coder.discuss.event` (WebSocket-only, like `coder.event`).
pub async fn discuss_send(
    client: &DaemonClient,
    discussion_id: &str,
    text: &str,
) -> Result<String, String> {
    client
        .call(
            "coder.discuss.send",
            json!({ "discussion_id": discussion_id, "text": text }),
        )
        .await
        .and_then(to_string)
}

/// `coder.discuss.promote` — distill the discussion into
/// `{proposed_intent, constraints}`. **Starts nothing**: no worktree, no
/// branch, no session. Callable repeatedly.
pub async fn discuss_promote(client: &DaemonClient, discussion_id: &str) -> Result<String, String> {
    client
        .call(
            "coder.discuss.promote",
            json!({ "discussion_id": discussion_id }),
        )
        .await
        .and_then(to_string)
}

/// `coder.discuss.close` — free the in-memory discussion.
pub async fn discuss_close(client: &DaemonClient, discussion_id: &str) -> Result<String, String> {
    client
        .call(
            "coder.discuss.close",
            json!({ "discussion_id": discussion_id }),
        )
        .await
        .and_then(to_string)
}

/// `coder.discuss.list` — open discussions. Also the capability probe: a daemon
/// predating discuss answers JSON-RPC `-32601`.
pub async fn discuss_list(client: &DaemonClient) -> Result<String, String> {
    client
        .call("coder.discuss.list", json!({}))
        .await
        .and_then(to_string)
}