use cm_domain::{AgentId, UserId, WorkspaceId}; use sqlx::PgPool; use uuid::Uuid; use crate::DbError; /// A skill as listed in the library (§8.1) or installed on an agent (§7.5). #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct Skill { pub id: Uuid, /// `None` = catalog skill visible to every workspace. pub workspace_id: Option, pub title: String, pub author: String, pub description: String, pub body: String, pub installs: i32, } pub async fn create( pool: &PgPool, workspace_id: Option, title: &str, author: &str, description: &str, body: &str, ) -> Result { let id = Uuid::now_v7(); sqlx::query!( "INSERT INTO skills (id, workspace_id, title, author, description, body) VALUES ($1, $2, $3, $4, $5, $6)", id, workspace_id.map(|w| w.as_uuid()), title, author, description, body, ) .execute(pool) .await?; Ok(Skill { id, workspace_id: workspace_id.map(|w| w.as_uuid()), title: title.to_owned(), author: author.to_owned(), description: description.to_owned(), body: body.to_owned(), installs: 0, }) } /// The Skill Library (§8.1): catalog skills plus this workspace's own. pub async fn library(pool: &PgPool, workspace_id: WorkspaceId) -> Result, DbError> { let rows = sqlx::query_as!( Skill, r#"SELECT id, workspace_id, title, author, description, body, installs FROM skills WHERE workspace_id IS NULL OR workspace_id = $1 ORDER BY installs DESC, title"#, workspace_id.as_uuid(), ) .fetch_all(pool) .await?; Ok(rows) } /// Skills installed on one agent (§7.5). pub async fn installed(pool: &PgPool, agent_id: AgentId) -> Result, DbError> { let rows = sqlx::query_as!( Skill, r#"SELECT s.id, s.workspace_id, s.title, s.author, s.description, s.body, s.installs FROM skills s JOIN installed_skills i ON i.skill_id = s.id WHERE i.agent_id = $1 ORDER BY i.installed_at"#, agent_id.as_uuid(), ) .fetch_all(pool) .await?; Ok(rows) } /// Installs a skill on an agent and bumps the library counter. Repeat /// installs are idempotent (no double count). pub async fn install( pool: &PgPool, agent_id: AgentId, skill_id: Uuid, installed_by: UserId, ) -> Result<(), DbError> { let mut tx = pool.begin().await.map_err(DbError::from)?; let inserted = sqlx::query!( "INSERT INTO installed_skills (agent_id, skill_id, installed_by) VALUES ($1, $2, $3) ON CONFLICT (agent_id, skill_id) DO NOTHING", agent_id.as_uuid(), skill_id, installed_by.as_uuid(), ) .execute(&mut *tx) .await?; if inserted.rows_affected() == 1 { sqlx::query!( "UPDATE skills SET installs = installs + 1 WHERE id = $1", skill_id, ) .execute(&mut *tx) .await?; } tx.commit().await.map_err(DbError::from)?; Ok(()) } pub async fn uninstall(pool: &PgPool, agent_id: AgentId, skill_id: Uuid) -> Result<(), DbError> { let result = sqlx::query!( "DELETE FROM installed_skills WHERE agent_id = $1 AND skill_id = $2", agent_id.as_uuid(), skill_id, ) .execute(pool) .await?; if result.rows_affected() == 0 { return Err(DbError::NotFound); } Ok(()) }