//! Level-up proposals — Slice 8.5. //! //! A proposal is a diff of "current state → suggested state" for an //! agent or a team. Reviewers approve a subset of items; the applier //! commits only those. All shape lives in `payload` JSONB — this //! module is pure storage. use serde::{Deserialize, Serialize}; use serde_json::Value; use sqlx::PgPool; use sqlx::Row; use time::OffsetDateTime; use uuid::Uuid; use crate::DbError; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LevelUpProposal { pub id: Uuid, pub workspace_id: Uuid, pub agent_id: Option, pub team_id: Option, pub status: String, pub payload: Value, pub applied_items: Vec, pub model: Option, pub created_by: Option, pub approved_by: Option, #[serde(with = "time::serde::rfc3339")] pub created_at: OffsetDateTime, #[serde(with = "time::serde::rfc3339::option")] pub applied_at: Option, } #[derive(Debug, Clone)] pub struct NewProposal<'a> { pub workspace_id: Uuid, pub agent_id: Option, pub team_id: Option, pub payload: &'a Value, pub model: Option<&'a str>, pub created_by: Option, } pub async fn insert(pool: &PgPool, p: NewProposal<'_>) -> Result { let id = Uuid::now_v7(); sqlx::query( "INSERT INTO level_up_proposals (id, workspace_id, agent_id, team_id, status, payload, model, created_by) VALUES ($1,$2,$3,$4,'pending',$5,$6,$7)", ) .bind(id) .bind(p.workspace_id) .bind(p.agent_id) .bind(p.team_id) .bind(p.payload) .bind(p.model) .bind(p.created_by) .execute(pool) .await?; Ok(id) } pub async fn get( pool: &PgPool, id: Uuid, workspace_id: Uuid, ) -> Result, DbError> { let row = sqlx::query( "SELECT id, workspace_id, agent_id, team_id, status, payload, applied_items, model, created_by, approved_by, created_at, applied_at FROM level_up_proposals WHERE id = $1 AND workspace_id = $2", ) .bind(id) .bind(workspace_id) .fetch_optional(pool) .await?; Ok(row.map(row_to_proposal)) } pub async fn list_pending( pool: &PgPool, workspace_id: Uuid, ) -> Result, DbError> { let rows = sqlx::query( "SELECT id, workspace_id, agent_id, team_id, status, payload, applied_items, model, created_by, approved_by, created_at, applied_at FROM level_up_proposals WHERE workspace_id = $1 AND status = 'pending' ORDER BY created_at DESC", ) .bind(workspace_id) .fetch_all(pool) .await?; Ok(rows.into_iter().map(row_to_proposal).collect()) } pub async fn mark_applied( pool: &PgPool, id: Uuid, workspace_id: Uuid, approved_by: Uuid, applied_items: &[String], partial: bool, ) -> Result<(), DbError> { let status = if partial { "partial" } else { "applied" }; sqlx::query( "UPDATE level_up_proposals SET status = $3, applied_items = $4, approved_by = $5, applied_at = now() WHERE id = $1 AND workspace_id = $2", ) .bind(id) .bind(workspace_id) .bind(status) .bind(applied_items) .bind(approved_by) .execute(pool) .await?; Ok(()) } pub async fn mark_rejected( pool: &PgPool, id: Uuid, workspace_id: Uuid, approved_by: Uuid, ) -> Result<(), DbError> { sqlx::query( "UPDATE level_up_proposals SET status = 'rejected', approved_by = $3, applied_at = now() WHERE id = $1 AND workspace_id = $2", ) .bind(id) .bind(workspace_id) .bind(approved_by) .execute(pool) .await?; Ok(()) } fn row_to_proposal(r: sqlx::postgres::PgRow) -> LevelUpProposal { LevelUpProposal { id: r.get("id"), workspace_id: r.get("workspace_id"), agent_id: r.get("agent_id"), team_id: r.get("team_id"), status: r.get("status"), payload: r.get("payload"), applied_items: r.get("applied_items"), model: r.get("model"), created_by: r.get("created_by"), approved_by: r.get("approved_by"), created_at: r.get("created_at"), applied_at: r.get("applied_at"), } }