Skip to main content

ares/db/
agent_versions.rs

1//! Database operations for agent_config_versions table.
2//!
3//! Stores a snapshot of every agent TOON config on startup and on hot-reload,
4//! enabling version history, auditing, and rollback (Sprint 12).
5//!
6//! Schema (migration 008):
7//!   id, agent_id, version, config_json (JSONB), is_active, change_source, created_at
8
9use anyhow::Result;
10use sqlx::PgPool;
11use tracing::{info, instrument, warn};
12
13use crate::utils::toon_config::ToonAgentConfig;
14
15/// Record a batch of agent configs into `agent_config_versions`.
16/// Called on startup (change_source="startup") and on hot-reload (change_source="hot_reload").
17///
18/// Uses INSERT ... ON CONFLICT (agent_id, version) DO NOTHING so the same version
19/// is never duplicated — only genuinely new or changed versions create rows.
20#[instrument(skip(pool, agents), fields(count = agents.len()))]
21pub async fn record_agent_versions(
22    pool: &PgPool,
23    agents: &[ToonAgentConfig],
24    change_source: &str,
25) -> Result<()> {
26    let mut recorded = 0usize;
27
28    for agent in agents {
29        let config_json = serde_json::to_value(agent)
30            .unwrap_or_else(|_| serde_json::json!({"name": agent.name}));
31
32        // For rollback events we need the row to be updated even if the version
33        // already exists (so the rollback is durably recorded). For startup /
34        // hot-reload we keep DO NOTHING to avoid noisy duplicates.
35        let sql = if change_source == "rollback" {
36            "INSERT INTO agent_config_versions \
37             (agent_id, version, config_json, is_active, change_source) \
38             VALUES ($1, $2, $3, true, $4) \
39             ON CONFLICT (agent_id, version) DO UPDATE \
40             SET change_source = EXCLUDED.change_source, is_active = true"
41        } else {
42            "INSERT INTO agent_config_versions \
43             (agent_id, version, config_json, is_active, change_source) \
44             VALUES ($1, $2, $3, true, $4) \
45             ON CONFLICT (agent_id, version) DO NOTHING"
46        };
47
48        match sqlx::query(sql)
49        .bind(&agent.name)
50        .bind(&agent.version)
51        .bind(&config_json)
52        .bind(change_source)
53        .execute(pool)
54        .await
55        {
56            Ok(r) if r.rows_affected() > 0 => {
57                recorded += 1;
58            }
59            Ok(_) => {
60                // Already recorded — skip silently (same version)
61            }
62            Err(e) => {
63                warn!(
64                    agent = %agent.name,
65                    version = %agent.version,
66                    error = %e,
67                    "Failed to record agent version"
68                );
69            }
70        }
71    }
72
73    if recorded > 0 {
74        info!(
75            recorded,
76            source = change_source,
77            "Agent config versions recorded"
78        );
79    }
80
81    Ok(())
82}
83
84/// Get the version history for a specific agent (most recent first)
85pub async fn get_agent_version_history(
86    pool: &PgPool,
87    agent_id: &str,
88    limit: i64,
89) -> Result<Vec<AgentVersionRecord>> {
90    // Runtime `query_as` (not the `query_as!` macro) — same reason as
91    // usage.rs: library crates shipped via crates.io cannot assume a
92    // DATABASE_URL env var or `.sqlx` cache at downstream compile time.
93    let rows = sqlx::query_as::<_, AgentVersionRecord>(
94        r#"SELECT id, agent_id, version, config_json, is_active, change_source, created_at
95           FROM agent_config_versions
96           WHERE agent_id = $1
97           ORDER BY created_at DESC
98           LIMIT $2"#,
99    )
100    .bind(agent_id)
101    .bind(limit)
102    .fetch_all(pool)
103    .await?;
104
105    Ok(rows)
106}
107
108/// A row from agent_config_versions
109#[derive(Debug, Clone, serde::Serialize, sqlx::FromRow)]
110pub struct AgentVersionRecord {
111    pub id: String,
112    pub agent_id: String,
113    pub version: String,
114    pub config_json: serde_json::Value,
115    pub is_active: bool,
116    pub change_source: String,
117    pub created_at: chrono::DateTime<chrono::Utc>,
118}