asurada 0.3.0

Asurada — a memory + cognition daemon that grows with the user. Local-first, BYOK, shared by Devist/Webchemist Core/etc.
#![allow(dead_code)]

//! 패턴 메타데이터 CRUD.
//!
//! 패턴 자체는 디스크 파일 (.claude/agents, .claude/skills 등) 이지만,
//! "왜 만들어졌나, 얼마나 쓰이나, 어떻게 진화하나" 같은 메타데이터는
//! 이 테이블에서 관리한다. Devist Dashboard 가 이걸 읽어 사용자에게 노출.

use anyhow::{Context, Result};
use chrono::Utc;
use rusqlite::{params, Connection};
use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Pattern {
    pub id: String,
    pub user_id: String,
    pub project: String,
    pub slug: String,
    pub title: String,
    pub description: String,
    pub reason: String,
    pub file_paths: Vec<String>,
    pub usage_count: i64,
    pub last_used_at: Option<String>,
    pub evolution_log: Vec<EvolutionEntry>,
    pub source_signal_ids: Vec<String>,
    pub source_cluster_signature: Option<i64>,
    pub status: String,
    pub metadata: serde_json::Value,
    pub created_at: String,
    pub updated_at: String,
    pub synced_at: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EvolutionEntry {
    pub at: String,
    /// 'created' | 'evolved' | 'deprecated' | 'manual_edit_detected'
    pub kind: String,
    pub summary: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub source_advice_id: Option<String>,
}

#[derive(Debug, Clone)]
pub struct PatternInput {
    pub user_id: String,
    pub project: String,
    pub slug: String,
    pub title: String,
    pub description: String,
    pub reason: String,
    pub file_paths: Vec<String>,
    pub source_signal_ids: Vec<String>,
    pub source_cluster_signature: Option<i64>,
    pub metadata: serde_json::Value,
}

pub fn insert(conn: &Connection, input: PatternInput) -> Result<Pattern> {
    let id = super::uuid_like();
    let now = Utc::now().to_rfc3339();
    let initial_log = vec![EvolutionEntry {
        at: now.clone(),
        kind: "created".into(),
        summary: format!(
            "초기 생성 — 신호 {}개에서 도출",
            input.source_signal_ids.len()
        ),
        source_advice_id: None,
    }];

    conn.execute(
        r#"INSERT INTO patterns
           (id, user_id, project, slug, title, description, reason,
            file_paths, evolution_log, source_signal_ids,
            source_cluster_signature, status, metadata, created_at, updated_at)
           VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10,
                   ?11, 'active', ?12, ?13, ?13)"#,
        params![
            id,
            input.user_id,
            input.project,
            input.slug,
            input.title,
            input.description,
            input.reason,
            serde_json::to_string(&input.file_paths)?,
            serde_json::to_string(&initial_log)?,
            serde_json::to_string(&input.source_signal_ids)?,
            input.source_cluster_signature,
            input.metadata.to_string(),
            now,
        ],
    )
    .context("insert pattern")?;

    get(conn, &input.user_id, &id)?.context("pattern missing after insert")
}

pub fn get(conn: &Connection, user_id: &str, id: &str) -> Result<Option<Pattern>> {
    let mut stmt = conn.prepare(SELECT_ALL_FROM_HARNESSES)?;
    Ok(stmt.query_row(params![user_id, id], row_to_pattern).ok())
}

pub fn get_by_slug(
    conn: &Connection,
    user_id: &str,
    project: &str,
    slug: &str,
) -> Result<Option<Pattern>> {
    let mut stmt = conn.prepare(
        r#"SELECT id, user_id, project, slug, title, description, reason,
                  file_paths, usage_count, last_used_at, evolution_log,
                  source_signal_ids, source_cluster_signature, status, metadata,
                  created_at, updated_at, synced_at
           FROM patterns
           WHERE user_id = ?1 AND project = ?2 AND slug = ?3"#,
    )?;
    Ok(stmt
        .query_row(params![user_id, project, slug], row_to_pattern)
        .ok())
}

pub fn list(conn: &Connection, user_id: &str, project: Option<&str>) -> Result<Vec<Pattern>> {
    if let Some(p) = project {
        let mut stmt = conn.prepare(
            r#"SELECT id, user_id, project, slug, title, description, reason,
                      file_paths, usage_count, last_used_at, evolution_log,
                      source_signal_ids, source_cluster_signature, status, metadata,
                      created_at, updated_at, synced_at
               FROM patterns
               WHERE user_id = ?1 AND project = ?2 AND status = 'active'
               ORDER BY last_used_at DESC NULLS LAST, created_at DESC"#,
        )?;
        let rows: Vec<Pattern> = stmt
            .query_map(params![user_id, p], row_to_pattern)?
            .filter_map(|r| r.ok())
            .collect();
        Ok(rows)
    } else {
        let mut stmt = conn.prepare(
            r#"SELECT id, user_id, project, slug, title, description, reason,
                      file_paths, usage_count, last_used_at, evolution_log,
                      source_signal_ids, source_cluster_signature, status, metadata,
                      created_at, updated_at, synced_at
               FROM patterns
               WHERE user_id = ?1 AND status = 'active'
               ORDER BY last_used_at DESC NULLS LAST, created_at DESC"#,
        )?;
        let rows: Vec<Pattern> = stmt
            .query_map(params![user_id], row_to_pattern)?
            .filter_map(|r| r.ok())
            .collect();
        Ok(rows)
    }
}

/// signature 매칭 활성 패턴 — 5a 메모리 주입 시 사용.
pub fn list_by_signature(conn: &Connection, user_id: &str, signature: i64) -> Result<Vec<Pattern>> {
    let mut stmt = conn.prepare(
        r#"SELECT id, user_id, project, slug, title, description, reason,
                  file_paths, usage_count, last_used_at, evolution_log,
                  source_signal_ids, source_cluster_signature, status, metadata,
                  created_at, updated_at, synced_at
           FROM patterns
           WHERE user_id = ?1 AND source_cluster_signature = ?2 AND status = 'active'
           ORDER BY usage_count DESC"#,
    )?;
    let rows: Vec<Pattern> = stmt
        .query_map(params![user_id, signature], row_to_pattern)?
        .filter_map(|r| r.ok())
        .collect();
    Ok(rows)
}

/// 사용 1회 기록 — 5c 의 use detection 에서 호출.
pub fn record_use(conn: &Connection, id: &str) -> Result<()> {
    let now = Utc::now().to_rfc3339();
    conn.execute(
        r#"UPDATE patterns
           SET usage_count = usage_count + 1,
               last_used_at = ?1,
               updated_at = ?1,
               synced_at = NULL
           WHERE id = ?2"#,
        params![now, id],
    )?;
    Ok(())
}

/// evolution_log 에 새 항목 append. 기존 로그 보존.
pub fn append_evolution(conn: &Connection, id: &str, entry: EvolutionEntry) -> Result<()> {
    let now = Utc::now().to_rfc3339();
    let mut stmt = conn.prepare("SELECT evolution_log FROM patterns WHERE id = ?1")?;
    let log_str: String = stmt
        .query_row(params![id], |r| r.get(0))
        .unwrap_or_else(|_| "[]".into());
    let mut log: Vec<EvolutionEntry> = serde_json::from_str(&log_str).unwrap_or_default();
    log.push(entry);
    conn.execute(
        r#"UPDATE patterns
           SET evolution_log = ?1, updated_at = ?2, synced_at = NULL
           WHERE id = ?3"#,
        params![serde_json::to_string(&log)?, now, id],
    )?;
    Ok(())
}

pub fn set_status(conn: &Connection, id: &str, status: &str) -> Result<()> {
    let now = Utc::now().to_rfc3339();
    conn.execute(
        r#"UPDATE patterns
           SET status = ?1, updated_at = ?2, synced_at = NULL
           WHERE id = ?3"#,
        params![status, now, id],
    )?;
    Ok(())
}

pub fn list_unsynced(conn: &Connection, limit: usize) -> Result<Vec<Pattern>> {
    let mut stmt = conn.prepare(
        r#"SELECT id, user_id, project, slug, title, description, reason,
                  file_paths, usage_count, last_used_at, evolution_log,
                  source_signal_ids, source_cluster_signature, status, metadata,
                  created_at, updated_at, synced_at
           FROM patterns
           WHERE synced_at IS NULL OR updated_at > synced_at
           ORDER BY updated_at ASC
           LIMIT ?1"#,
    )?;
    let rows: Vec<Pattern> = stmt
        .query_map(params![limit as i64], row_to_pattern)?
        .filter_map(|r| r.ok())
        .collect();
    Ok(rows)
}

pub fn mark_synced(conn: &Connection, ids: &[&str], when: &str) -> Result<()> {
    if ids.is_empty() {
        return Ok(());
    }
    let placeholders = ids.iter().map(|_| "?").collect::<Vec<_>>().join(",");
    let sql = format!(
        "UPDATE patterns SET synced_at = ? WHERE id IN ({})",
        placeholders
    );
    let mut stmt = conn.prepare(&sql)?;
    let mut binds: Vec<rusqlite::types::Value> = Vec::with_capacity(ids.len() + 1);
    binds.push(when.to_string().into());
    for id in ids {
        binds.push((*id).to_string().into());
    }
    stmt.execute(rusqlite::params_from_iter(binds.iter()))?;
    Ok(())
}

const SELECT_ALL_FROM_HARNESSES: &str = r#"
    SELECT id, user_id, project, slug, title, description, reason,
           file_paths, usage_count, last_used_at, evolution_log,
           source_signal_ids, source_cluster_signature, status, metadata,
           created_at, updated_at, synced_at
    FROM patterns
    WHERE user_id = ?1 AND id = ?2
"#;

fn row_to_pattern(row: &rusqlite::Row<'_>) -> rusqlite::Result<Pattern> {
    let file_paths_str: String = row.get(7)?;
    let evolution_log_str: String = row.get(10)?;
    let source_ids_str: String = row.get(11)?;
    let metadata_str: String = row.get(14)?;
    Ok(Pattern {
        id: row.get(0)?,
        user_id: row.get(1)?,
        project: row.get(2)?,
        slug: row.get(3)?,
        title: row.get(4)?,
        description: row.get(5)?,
        reason: row.get(6)?,
        file_paths: serde_json::from_str(&file_paths_str).unwrap_or_default(),
        usage_count: row.get(8)?,
        last_used_at: row.get(9)?,
        evolution_log: serde_json::from_str(&evolution_log_str).unwrap_or_default(),
        source_signal_ids: serde_json::from_str(&source_ids_str).unwrap_or_default(),
        source_cluster_signature: row.get(12)?,
        status: row.get(13)?,
        metadata: serde_json::from_str(&metadata_str).unwrap_or_else(|_| serde_json::json!({})),
        created_at: row.get(15)?,
        updated_at: row.get(16)?,
        synced_at: row.get(17)?,
    })
}