remem-ai 0.6.81

Local-first coding agent memory for Claude Code and OpenAI Codex
Documentation
//! Rule-compilation enqueue helpers shared by preference lifecycle mutations.
//!
//! These helpers only schedule worker jobs. They never compile or write the
//! derived artifact in a caller process.

use anyhow::{Context, Result};
use rusqlite::{params, params_from_iter, Connection};

use crate::memory::suppression::SuppressionTarget;

#[derive(Debug)]
struct AffectedPreference {
    project: String,
    global: bool,
}

const MEMORY_ID_QUERY_BATCH_SIZE: usize = 500;

const PREFERENCE_AUTHORITY_PROJECT_SQL: &str = "CASE
       WHEN COALESCE(m.scope, 'project') = 'global' THEN m.project
       ELSE COALESCE(
           NULLIF(m.target_project, ''),
           CASE WHEN m.owner_scope = 'repo' THEN NULLIF(m.owner_key, '') END,
           m.project
       )
   END";

const SUPPRESSION_TARGET_MATCH_SQL: &str = "(
       (?1 = 'memory' AND ?2 IS NOT NULL AND m.id = ?2)
    OR (?1 = 'topic_key' AND ?3 IS NOT NULL AND m.topic_key = ?3)
    OR (?1 = 'entity' AND ?3 IS NOT NULL AND EXISTS (
           SELECT 1
           FROM memory_entities ms_me
           JOIN entities ms_e ON ms_e.id = ms_me.entity_id
           WHERE ms_me.memory_id = m.id
             AND lower(ms_e.canonical_name) = lower(?3)
       ))
    OR (?1 = 'pattern' AND ?3 IS NOT NULL AND (
           instr(lower(m.title), lower(?3)) > 0
        OR instr(lower(m.content), lower(?3)) > 0
       ))
)";

pub(crate) fn enqueue_for_memory_ids(conn: &Connection, memory_ids: &[i64]) -> Result<()> {
    if memory_ids.is_empty() {
        return Ok(());
    }
    let mut unique = memory_ids
        .iter()
        .copied()
        .filter(|id| *id > 0)
        .collect::<Vec<_>>();
    unique.sort_unstable();
    unique.dedup();
    if unique.is_empty() {
        return Ok(());
    }

    let mut affected = Vec::new();
    for memory_id_batch in unique.chunks(MEMORY_ID_QUERY_BATCH_SIZE) {
        let placeholders = std::iter::repeat_n("?", memory_id_batch.len())
            .collect::<Vec<_>>()
            .join(", ");
        let preference_check_sql = format!(
            "SELECT EXISTS(
                 SELECT 1 FROM memories
                 WHERE memory_type = 'preference' AND id IN ({placeholders})
             )"
        );
        let has_preferences: bool = conn.query_row(
            &preference_check_sql,
            params_from_iter(memory_id_batch.iter()),
            |row| row.get(0),
        )?;
        if !has_preferences {
            continue;
        }
        let sql = format!(
            "SELECT {PREFERENCE_AUTHORITY_PROJECT_SQL},
                    COALESCE(m.scope, 'project') = 'global'
             FROM memories m
             JOIN memory_preference_reinforcements r ON r.memory_id = m.id
             WHERE m.memory_type = 'preference' AND m.id IN ({placeholders})"
        );
        let mut stmt = conn.prepare(&sql)?;
        let rows = stmt.query_map(params_from_iter(memory_id_batch.iter()), |row| {
            Ok(AffectedPreference {
                project: row.get(0)?,
                global: row.get(1)?,
            })
        })?;
        affected.extend(crate::db::query::collect_rows(rows)?);
    }
    enqueue_affected(conn, affected)
}

pub(crate) fn enqueue_for_suppression_targets(
    conn: &Connection,
    targets: &[SuppressionTarget],
) -> Result<()> {
    let mut affected = Vec::new();
    for target in targets {
        let exists_sql = format!(
            "SELECT EXISTS(
                 SELECT 1 FROM memories m
                 WHERE m.memory_type = 'preference'
                   AND m.status = 'active'
                   AND {SUPPRESSION_TARGET_MATCH_SQL}
             )"
        );
        let matches_preference: bool = conn.query_row(
            &exists_sql,
            params![target.kind, target.id, target.value],
            |row| row.get(0),
        )?;
        if !matches_preference {
            continue;
        }
        let affected_sql = format!(
            "SELECT {PREFERENCE_AUTHORITY_PROJECT_SQL},
                    COALESCE(m.scope, 'project') = 'global'
             FROM memories m
             JOIN memory_preference_reinforcements r ON r.memory_id = m.id
             WHERE m.memory_type = 'preference'
               AND m.status = 'active'
               AND {SUPPRESSION_TARGET_MATCH_SQL}"
        );
        let mut stmt = conn.prepare(&affected_sql)?;
        let rows = stmt.query_map(params![target.kind, target.id, target.value], |row| {
            Ok(AffectedPreference {
                project: row.get(0)?,
                global: row.get(1)?,
            })
        })?;
        affected.extend(crate::db::query::collect_rows(rows)?);
    }
    enqueue_affected(conn, affected)
}

fn enqueue_affected(conn: &Connection, affected: Vec<AffectedPreference>) -> Result<()> {
    if affected.is_empty() {
        return Ok(());
    }
    let config = crate::runtime_config::rule_compilation_config()
        .context("read rule compilation config before enqueue")?;
    if !config.enabled {
        return Ok(());
    }
    let has_global = affected.iter().any(|preference| preference.global);
    let mut projects = affected
        .into_iter()
        .map(|preference| preference.project)
        .collect::<std::collections::BTreeSet<_>>();
    if has_global {
        projects.extend(known_compilation_projects(conn)?);
    }
    enqueue_projects(conn, projects)
}

fn known_compilation_projects(conn: &Connection) -> Result<Vec<String>> {
    let mut stmt = conn.prepare(
        "SELECT project FROM preference_rule_diagnostics
         UNION
         SELECT project FROM preference_rule_overrides
         UNION
         SELECT project FROM jobs WHERE job_type = 'compile_rules'
         UNION
         SELECT project_path FROM projects
         UNION
         SELECT CASE
                    WHEN COALESCE(m.scope, 'project') = 'global' THEN m.project
                    ELSE COALESCE(
                        NULLIF(m.target_project, ''),
                        CASE WHEN m.owner_scope = 'repo' THEN NULLIF(m.owner_key, '') END,
                        m.project
                    )
                END
         FROM memories m
         JOIN memory_preference_reinforcements r ON r.memory_id = m.id",
    )?;
    let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
    crate::db::query::collect_rows(rows)
}

fn enqueue_projects(conn: &Connection, projects: impl IntoIterator<Item = String>) -> Result<()> {
    let config = crate::runtime_config::rule_compilation_config()
        .context("read rule compilation config before enqueue")?;
    if !config.enabled {
        return Ok(());
    }
    enqueue_projects_enabled(conn, projects)
}

fn enqueue_projects_enabled(
    conn: &Connection,
    projects: impl IntoIterator<Item = String>,
) -> Result<()> {
    let projects = projects
        .into_iter()
        .filter(|project| !project.trim().is_empty())
        .collect::<std::collections::BTreeSet<_>>();
    if projects.is_empty() {
        return Ok(());
    }
    for project in projects {
        crate::db::enqueue_job(
            conn,
            "worker",
            crate::db::JobType::CompileRules,
            &project,
            None,
            "{}",
            100,
        )
        .with_context(|| format!("enqueue rule compilation for {project}"))?;
    }
    Ok(())
}

pub(crate) fn enqueue_project_required(conn: &Connection, project: &str) -> Result<()> {
    enqueue_projects_enabled(conn, std::iter::once(project.to_string()))
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::db::test_support::ScopedTestDataDir;

    #[test]
    fn enabled_enqueue_failure_is_propagated() -> Result<()> {
        let _dir = ScopedTestDataDir::new("preference-enqueue-error");
        crate::runtime_config::init_config()?;
        crate::runtime_config::set_config_value("rule_compilation.enabled", "true")?;
        let conn = Connection::open_in_memory()?;

        let error = enqueue_projects(&conn, std::iter::once("/repo".to_string()))
            .expect_err("missing jobs table must not be swallowed when compilation is enabled");
        assert!(
            error.to_string().contains("enqueue rule compilation"),
            "{error:#}"
        );
        Ok(())
    }

    #[test]
    fn malformed_enqueue_config_is_propagated() -> Result<()> {
        let _dir = ScopedTestDataDir::new("preference-enqueue-config-error");
        std::fs::create_dir_all(crate::db::data_dir())?;
        std::fs::write(
            crate::runtime_config::config_path()?,
            "[rule_compilation]\nenabled = 'yes'\n",
        )?;
        let conn = Connection::open_in_memory()?;

        let error = enqueue_projects(&conn, std::iter::once("/repo".to_string()))
            .expect_err("malformed compilation config must not be swallowed");
        assert!(
            error.to_string().contains("read rule compilation config"),
            "{error:#}"
        );
        Ok(())
    }

    #[test]
    fn global_preference_enqueues_each_known_compilation_project() -> Result<()> {
        let _dir = ScopedTestDataDir::new("global-preference-enqueue");
        crate::runtime_config::init_config()?;
        crate::runtime_config::set_config_value("rule_compilation.enabled", "true")?;
        let conn = crate::db::open_db()?;
        conn.execute(
            "INSERT INTO memories
             (id, project, title, content, memory_type, created_at_epoch, updated_at_epoch,
              status, scope)
             VALUES (1, '/source', 'Preference', 'Use bun, not npm', 'preference',
                     1, 1, 'active', 'global')",
            [],
        )?;
        conn.execute(
            "INSERT INTO memory_preference_reinforcements
             (memory_id, reinforcement_count, last_reinforced_at_epoch,
              created_at_epoch, updated_at_epoch, machine_checkable)
             VALUES (1, 3, 1, 1, 1, 1)",
            [],
        )?;
        conn.execute(
            "INSERT INTO preference_rule_diagnostics
             (project, event_kind, status, rule_count, occurred_at_epoch)
             VALUES ('/consumer', 'compile', 'ok', 0, 1)",
            [],
        )?;

        enqueue_for_memory_ids(&conn, &[1])?;

        let mut stmt = conn.prepare(
            "SELECT project FROM jobs
             WHERE job_type = 'compile_rules' AND state = 'pending'
             ORDER BY project",
        )?;
        let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
        assert_eq!(
            crate::db::query::collect_rows(rows)?,
            vec!["/consumer".to_string(), "/source".to_string()]
        );
        Ok(())
    }

    #[test]
    fn global_preference_fans_out_to_registered_project_without_rule_state() -> Result<()> {
        let _dir = ScopedTestDataDir::new("global-preference-project-registry");
        crate::runtime_config::init_config()?;
        crate::runtime_config::set_config_value("rule_compilation.enabled", "true")?;
        let conn = crate::db::open_db()?;
        conn.execute(
            "INSERT INTO workspaces
             (id, root_path, created_at_epoch, updated_at_epoch)
             VALUES (1, '/workspace', 1, 1)",
            [],
        )?;
        conn.execute(
            "INSERT INTO projects
             (id, workspace_id, project_path, project_key, created_at_epoch, updated_at_epoch)
             VALUES (1, 1, '/consumer-without-state', 'consumer', 1, 1)",
            [],
        )?;
        conn.execute(
            "INSERT INTO memories
             (id, project, title, content, memory_type, created_at_epoch, updated_at_epoch,
              status, scope)
             VALUES (1, '/source', 'Preference', 'Use bun, not npm', 'preference',
                     1, 1, 'active', 'global')",
            [],
        )?;
        conn.execute(
            "INSERT INTO memory_preference_reinforcements
             (memory_id, reinforcement_count, last_reinforced_at_epoch,
              created_at_epoch, updated_at_epoch, machine_checkable)
             VALUES (1, 3, 1, 1, 1, 1)",
            [],
        )?;

        enqueue_for_memory_ids(&conn, &[1])?;

        let registered: i64 = conn.query_row(
            "SELECT COUNT(*) FROM jobs
             WHERE job_type = 'compile_rules'
               AND project = '/consumer-without-state'
               AND state = 'pending'",
            [],
            |row| row.get(0),
        )?;
        assert_eq!(registered, 1);
        Ok(())
    }

    #[test]
    fn memory_id_queries_cover_preferences_across_bounded_batches() -> Result<()> {
        let _dir = ScopedTestDataDir::new("preference-enqueue-bounded-ids");
        crate::runtime_config::init_config()?;
        crate::runtime_config::set_config_value("rule_compilation.enabled", "true")?;
        let conn = crate::db::open_db()?;
        let preference_id = (MEMORY_ID_QUERY_BATCH_SIZE as i64 * 80) + 1;
        conn.execute(
            "INSERT INTO memories
             (id, project, title, content, memory_type, created_at_epoch, updated_at_epoch,
              status, scope)
             VALUES (?1, '/later-batch', 'Preference', 'Use cargo nextest',
                     'preference', 1, 1, 'active', 'project')",
            [preference_id],
        )?;
        conn.execute(
            "INSERT INTO memory_preference_reinforcements
             (memory_id, reinforcement_count, last_reinforced_at_epoch,
              created_at_epoch, updated_at_epoch, machine_checkable)
             VALUES (?1, 3, 1, 1, 1, 1)",
            [preference_id],
        )?;
        let ids = (1..=preference_id).collect::<Vec<_>>();

        enqueue_for_memory_ids(&conn, &ids)?;

        let queued: i64 = conn.query_row(
            "SELECT COUNT(*) FROM jobs
             WHERE job_type = 'compile_rules'
               AND project = '/later-batch'
               AND state = 'pending'",
            [],
            |row| row.get(0),
        )?;
        assert_eq!(queued, 1);
        Ok(())
    }
}