car-inference 0.25.0

Local model inference for CAR — Candle backend with Qwen3 models
//! Concierge action ledger (Phase D2) — an auditable, append-only record
//! of every consented action the concierge takes (install, set-default,
//! rollback). Two jobs:
//!
//! 1. **Audit** — the user (and a reviewer) can see exactly what the
//!    concierge did and when. Every entry is user-consented: it is only
//!    written when the user invokes the action.
//! 2. **Reversibility** — each entry captures the prior state
//!    (`prior_model_id`), so a default change can be undone. Rollback is
//!    what earns the concierge permission to act at all, and it's only
//!    possible if "what was there before" is durably recorded.
//!
//! Append-only JSONL at `~/.car/concierge-actions.jsonl`, owner-only
//! (0600). Carries no prompt/output text.

use std::path::{Path, PathBuf};

use serde::{Deserialize, Serialize};

use crate::intent::UseCase;

pub const ACTION_LEDGER_FILE: &str = "concierge-actions.jsonl";

/// What the concierge did.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ConciergeActionKind {
    /// Acquired (pulled) a model.
    Install,
    /// Set a lane's default model.
    SetDefault,
    /// Cleared a lane default.
    ClearDefault,
    /// Reverted a lane default to its prior value.
    Rollback,
}

/// One consented action.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ConciergeActionEntry {
    /// Monotonic per-process sequence number, assigned at append time.
    /// Used as the canary's anchor identity instead of the whole-second
    /// `timestamp` (two actions in the same second would otherwise share
    /// an identity). `#[serde(default)]` → pre-upgrade entries load as 0.
    #[serde(default)]
    pub seq: u64,
    pub kind: ConciergeActionKind,
    pub model_id: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub use_case: Option<UseCase>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub project: Option<String>,
    /// The lane's default *before* this action — the value a rollback
    /// restores. `None` means "no prior default" (rollback clears).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub prior_model_id: Option<String>,
    #[serde(default)]
    pub detail: String,
    pub timestamp: u64,
}

/// Result of a closed-loop `apply` (Phase D3).
#[derive(Debug, Clone, Serialize)]
pub struct ConciergeApplyResult {
    pub model_id: String,
    pub use_case: UseCase,
    pub installed: bool,
    pub set_default: bool,
    /// The lane's prior default (what a rollback restores).
    pub prior_model_id: Option<String>,
}

/// Default path: `~/.car/concierge-actions.jsonl`.
pub fn default_path() -> PathBuf {
    let home = std::env::var_os("HOME")
        .or_else(|| std::env::var_os("USERPROFILE"))
        .map(PathBuf::from)
        .unwrap_or_else(|| PathBuf::from("."));
    home.join(".car").join(ACTION_LEDGER_FILE)
}

/// Append one action (owner-only file). Append-only: no rewrite.
pub fn append_action(path: &Path, entry: &ConciergeActionEntry) -> std::io::Result<()> {
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent)?;
    }
    use std::io::Write;
    let mut opts = std::fs::OpenOptions::new();
    opts.create(true).append(true);
    #[cfg(unix)]
    {
        use std::os::unix::fs::OpenOptionsExt;
        opts.mode(0o600);
    }
    let mut f = opts.open(path)?;
    let line = serde_json::to_string(entry)
        .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?;
    f.write_all(line.as_bytes())?;
    f.write_all(b"\n")
}

/// Read the most recent `limit` actions (0 = all); skips garbage lines.
pub fn read_actions(path: &Path, limit: usize) -> Vec<ConciergeActionEntry> {
    let Ok(content) = std::fs::read_to_string(path) else {
        return Vec::new();
    };
    let mut out: Vec<ConciergeActionEntry> = content
        .lines()
        .filter(|l| !l.trim().is_empty())
        .filter_map(|l| serde_json::from_str(l).ok())
        .collect();
    if limit > 0 && out.len() > limit {
        out = out.split_off(out.len() - limit);
    }
    out
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn append_and_read_roundtrip() {
        let dir = std::env::temp_dir().join("car-action-ledger-test");
        let _ = std::fs::remove_dir_all(&dir);
        let path = dir.join(ACTION_LEDGER_FILE);

        let e = ConciergeActionEntry {
            seq: 1,
            kind: ConciergeActionKind::SetDefault,
            model_id: "big-coder".into(),
            use_case: Some(UseCase::Coding),
            project: None,
            prior_model_id: Some("small-coder".into()),
            detail: "concierge apply".into(),
            timestamp: 100,
        };
        append_action(&path, &e).unwrap();
        let read = read_actions(&path, 0);
        assert_eq!(read.len(), 1);
        assert_eq!(read[0].kind, ConciergeActionKind::SetDefault);
        assert_eq!(read[0].prior_model_id.as_deref(), Some("small-coder"));
        let _ = std::fs::remove_dir_all(&dir);
    }
}