car-server-core 0.55.0

Transport-neutral library for the CAR daemon JSON-RPC dispatcher (used by car-server and tokhn-daemon)
Documentation
//! Local ownership and repository binding for durable conversations.
//! Model messages remain exclusively in the assistant checkpoint store.

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

use serde::{Deserialize, Serialize};

#[derive(Clone, Debug, Serialize, Deserialize)]
pub(super) struct DiscussionRecord {
    pub id: String,
    pub repo: PathBuf,
    pub principal: String,
    pub created_at: u64,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub model: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub pending_task: Option<(String, Vec<String>)>,
}

fn record_path(root: &Path, id: &str) -> Result<PathBuf, String> {
    let suffix = id.strip_prefix("disc-").ok_or("invalid conversation id")?;
    if suffix.len() != 32 || !suffix.bytes().all(|b| b.is_ascii_hexdigit()) {
        return Err("invalid conversation id".into());
    }
    Ok(root.join("coder-discussions").join(format!("{id}.json")))
}

impl DiscussionRecord {
    /// Enumerate only valid records owned by the authenticated caller. A damaged
    /// record must not hide unrelated conversations or expose another owner.
    pub fn list(root: &Path, principal: &str) -> Result<Vec<Self>, String> {
        let entries = match std::fs::read_dir(root.join("coder-discussions")) {
            Ok(entries) => entries,
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
            Err(e) => return Err(format!("read saved conversations: {e}")),
        };
        let mut records = Vec::new();
        for entry in entries {
            let entry = entry.map_err(|e| format!("read saved conversation: {e}"))?;
            let path = entry.path();
            if path.extension().and_then(|s| s.to_str()) != Some("json") {
                continue;
            }
            let Some(id) = path.file_stem().and_then(|s| s.to_str()) else {
                continue;
            };
            if let Ok(record) = Self::load(root, id, principal) {
                records.push(record);
            }
        }
        records.sort_by(|a, b| b.created_at.cmp(&a.created_at).then(a.id.cmp(&b.id)));
        Ok(records)
    }

    pub fn load(root: &Path, id: &str, principal: &str) -> Result<Self, String> {
        let path = record_path(root, id)?;
        let bytes =
            std::fs::read(path).map_err(|_| "saved conversation is unavailable".to_string())?;
        let record: Self = serde_json::from_slice(&bytes)
            .map_err(|_| "saved conversation record is invalid".to_string())?;
        if record.id != id || record.principal != principal {
            return Err("saved conversation is unavailable".into());
        }
        Ok(record)
    }

    pub fn save(&self, root: &Path) -> Result<(), String> {
        self.write(root, false)
    }

    /// Update conversation preferences or a pending task without replacing identity.
    /// The caller holds the recovery lock or owns the live discussion's turn/start guard.
    pub fn save_model(&self, root: &Path) -> Result<(), String> {
        let prior = Self::load(root, &self.id, &self.principal)?;
        if prior.repo != self.repo || prior.created_at != self.created_at {
            return Err("conversation identity changed while selecting a model".into());
        }
        self.write(root, true)
    }

    fn write(&self, root: &Path, replace: bool) -> Result<(), String> {
        use std::io::Write;
        let path = record_path(root, &self.id)?;
        let parent = path.parent().ok_or("invalid conversation path")?;
        car_secrets::ensure_private_dir(parent)
            .map_err(|e| format!("create private conversation store: {e}"))?;
        // Publish only a fully synced record, and never replace an existing
        // identity/owner binding. A crash cannot leave a partial JSON record.
        let mut file = tempfile::NamedTempFile::new_in(parent)
            .map_err(|e| format!("create conversation record: {e}"))?;
        let bytes = serde_json::to_vec(self).map_err(|e| e.to_string())?;
        file.write_all(&bytes)
            .and_then(|_| file.as_file().sync_all())
            .map_err(|e| format!("save conversation: {e}"))?;
        if replace {
            file.persist(&path)
        } else {
            file.persist_noclobber(&path)
        }
        .map_err(|e| format!("publish conversation record: {e}"))?;
        #[cfg(unix)]
        std::fs::File::open(parent)
            .and_then(|dir| dir.sync_all())
            .map_err(|e| format!("sync conversation directory: {e}"))?;
        Ok(())
    }
}

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

    #[test]
    fn ownership_binding_cannot_be_replaced_or_loaded_by_another_principal() {
        let dir = tempfile::tempdir().unwrap();
        let record = DiscussionRecord {
            id: format!("disc-{}", uuid::Uuid::new_v4().simple()),
            repo: dir.path().join("repo"),
            principal: "operator".into(),
            created_at: 1,
            model: None,
            pending_task: None,
        };
        record.save(dir.path()).unwrap();
        assert!(DiscussionRecord::load(dir.path(), &record.id, "agent:other").is_err());
        assert!(record.save(dir.path()).is_err());
        assert_eq!(
            DiscussionRecord::load(dir.path(), &record.id, "operator")
                .unwrap()
                .repo,
            record.repo
        );
        assert!(DiscussionRecord::load(dir.path(), "../../secret", "operator").is_err());
    }
}