OXYGENHUB

Coverage / crates/spectra-core/src/rules.rs 98.7%

75 of 76 lines run, 14 of 25 functions · run #21 on main · run missed

1
//! Beacon (spec §12): the house rules an agent receives, compiled from the
2
//! layers that exist, each under a heading naming it, with no other change;
3
//! the version is the BLAKE3 of that text. 0.1 has the site layer; kind,
4
//! project and agent layers, and committing them to the ledger, are 0.3.
5
6
use crate::event::Event;
7
use anyhow::{bail, Result};
8
use std::path::Path;
9
10
/// A rule file over this is refused (spec §12.2).
11
pub const MAX_RULES: usize = 64 * 1024;
12
13
/// Compiled rules and their version.
14
#[derive(Clone, Debug, PartialEq, Eq)]
15
pub struct Rules {
16
    pub text: String,
17
    /// The first 12 hex characters of BLAKE3(text).
18
    pub version: String,
19
}
20
21
/// The rules for an agent, from `<data>/rules/`.
22111×
pub fn compile(data: &Path) -> Result<Rules> {
23111×
    let mut text = String::new();
24111×
    let site = data.join("rules").join("site.md");
25111×
    if let Ok(body) = std::fs::read_to_string(&site) {
2657×
        if body.len() > MAX_RULES {
271×
            bail!("{} is over 64 KiB: the rules must stay readable in one call", site.display());
2856×
        }
2956×
        text.push_str("# House rules: site (rules/site.md)\n\n");
3056×
        text.push_str(body.trim_end());
3156×
        text.push('\n');
3254×
    }
33110×
    let version = blake3::hash(text.as_bytes()).to_hex()[..12].to_string();
34110×
    Ok(Rules { text, version })
35111×
}
36
37
/// The version `agent` last read, if any.
3815×
pub async fn last_read(pool: &sqlx::SqlitePool, agent: &str) -> sqlx::Result<Option<String>> {
3915×
    sqlx::query_scalar("SELECT version FROM rules_read WHERE agent = ?").bind(agent).fetch_optional(pool).await
4015×
}
41
42
/// The line every result begins with while an agent works on stale rules.
432×
pub fn changed_line(read: &str, now: &str) -> String {
442×
    format!("House rules changed ({read} → {now}): call rules before continuing.")
452×
}
46
47
/// Apply `rules.read` and `tool.called` to their tables.
4842608×
pub async fn project(conn: &mut sqlx::SqliteConnection, e: &Event) -> sqlx::Result<()> {
4942608×
    let p = e.json();
5042608×
    let agent = e.actor.strip_prefix("agent:");
5142608×
    match e.kind.as_str() {
5242608×
        "rules.read" => {
535×
            if let (Some(a), Some(v)) = (agent, p.get("version").and_then(|v| v.as_str())) {
545×
                sqlx::query("INSERT INTO rules_read (agent, version, at_ms) VALUES (?, ?, ?) ON CONFLICT(agent) DO UPDATE SET version = excluded.version, at_ms = excluded.at_ms")
555×
                    .bind(a)
565×
                    .bind(v)
575×
                    .bind(e.at_ms as i64)
585×
                    .execute(&mut *conn)
595×
                    .await?;
600×
            }
61
        }
6242603×
        "tool.called" => {
633353×
            let s = |k: &str| p.get(k).and_then(|v| v.as_str()).map(String::from);
643299×
            if let (Some(a), Some(tool)) = (agent, s("tool")) {
6527×
                sqlx::query("INSERT OR IGNORE INTO tool_calls (seq, agent, tool, args, result, ms, is_error, read, session, at_ms) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
6627×
                    .bind(e.seq as i64)
6727×
                    .bind(a)
6827×
                    .bind(tool)
6927×
                    .bind(s("args").unwrap_or_default())
7027×
                    .bind(s("result").unwrap_or_default())
7127×
                    .bind(p.get("ms").and_then(|v| v.as_i64()).unwrap_or(0))
7227×
                    .bind(p.get("is_error").and_then(|v| v.as_bool()).unwrap_or(false))
7327×
                    .bind(p.get("read").and_then(|v| v.as_bool()).unwrap_or(false))
7427×
                    .bind(&e.session)
7527×
                    .bind(e.at_ms as i64)
7627×
                    .execute(&mut *conn)
7727×
                    .await?;
783272×
            }
79
        }
8039304×
        _ => {}
81
    }
8242608×
    Ok(())
8342608×
}
84
85
#[cfg(test)]
86
mod tests {
87
    use super::*;
88
89
    #[test]
901×
    fn the_version_follows_the_text_exactly() {
911×
        let dir = tempfile::tempdir().unwrap();
921×
        let empty = compile(dir.path()).unwrap();
931×
        assert_eq!(empty.text, "");
941×
        std::fs::create_dir_all(dir.path().join("rules")).unwrap();
951×
        std::fs::write(dir.path().join("rules/site.md"), "Commit with oxy.\n").unwrap();
961×
        let a = compile(dir.path()).unwrap();
971×
        assert!(a.text.contains("Commit with oxy.") && a.text.starts_with("# House rules: site"));
981×
        std::fs::write(dir.path().join("rules/site.md"), "Commit with oxy!\n").unwrap();
991×
        let b = compile(dir.path()).unwrap();
1001×
        assert_ne!(a.version, b.version);
1011×
        assert_eq!(a.version.len(), 12);
1021×
        std::fs::write(dir.path().join("rules/site.md"), "x".repeat(MAX_RULES + 1)).unwrap();
1031×
        assert!(compile(dir.path()).is_err());
1041×
    }
105
}