//! Research topics — user-scoped inquiry containers that group agents around //! a shared question and drive them toward a named outcome (spec, prod_plan, //! roadmap, paper). The status column is a small state machine; see the //! 0030 migration header for the transitions. Runs are owned via //! `topology_runs.research_topic_id`, so all durable execution state lives //! there — this repo only manages the container + status + agent binding. use serde::{Deserialize, Serialize}; use sqlx::PgPool; use time::OffsetDateTime; use uuid::Uuid; use crate::DbError; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ResearchTopic { pub id: Uuid, pub workspace_id: Uuid, pub title: String, pub description: String, pub outcome_kind: String, pub status: String, pub created_by: Uuid, pub created_at: OffsetDateTime, pub updated_at: OffsetDateTime, pub published_at: Option, /// Topology shape start_topic builds when firing this topic. Options: /// hub_spoke, pipeline, hierarchical, star_moe. Defaults to hub_spoke. pub topology_kind: String, } #[derive(Debug, Clone, Serialize)] pub struct AgentSlot { pub agent_id: Uuid, pub role_slot: Option, } /// Creates a topic in `standby`. Returns the new row's id. pub async fn create( pool: &PgPool, workspace_id: Uuid, title: &str, description: &str, outcome_kind: &str, topology_kind: &str, created_by: Uuid, ) -> Result { let id = Uuid::now_v7(); sqlx::query!( "INSERT INTO research_topics (id, workspace_id, title, description, outcome_kind, topology_kind, status, created_by) VALUES ($1, $2, $3, $4, $5, $6, 'standby', $7)", id, workspace_id, title, description, outcome_kind, topology_kind, created_by, ) .execute(pool) .await?; Ok(id) } /// Workspace's topics, newest-updated first. pub async fn list(pool: &PgPool, workspace_id: Uuid) -> Result, DbError> { let rows = sqlx::query_as!( ResearchTopic, "SELECT id, workspace_id, title, description, outcome_kind, status, created_by, created_at, updated_at, published_at, topology_kind FROM research_topics WHERE workspace_id = $1 ORDER BY updated_at DESC", workspace_id, ) .fetch_all(pool) .await?; Ok(rows) } pub async fn get( pool: &PgPool, id: Uuid, workspace_id: Uuid, ) -> Result, DbError> { let row = sqlx::query_as!( ResearchTopic, "SELECT id, workspace_id, title, description, outcome_kind, status, created_by, created_at, updated_at, published_at, topology_kind FROM research_topics WHERE id = $1 AND workspace_id = $2", id, workspace_id, ) .fetch_optional(pool) .await?; Ok(row) } /// Non-status fields; the state machine transitions are their own endpoints. pub async fn update_fields( pool: &PgPool, id: Uuid, workspace_id: Uuid, title: &str, description: &str, outcome_kind: &str, ) -> Result<(), DbError> { sqlx::query!( "UPDATE research_topics SET title = $3, description = $4, outcome_kind = $5, updated_at = now() WHERE id = $1 AND workspace_id = $2", id, workspace_id, title, description, outcome_kind, ) .execute(pool) .await?; Ok(()) } /// State-machine transition. Caller enforces which transitions are valid; /// this is the single write path so we can bump `updated_at` (and /// `published_at` on landing in `publishing`). pub async fn set_status( pool: &PgPool, id: Uuid, workspace_id: Uuid, status: &str, ) -> Result<(), DbError> { sqlx::query!( "UPDATE research_topics SET status = $3, updated_at = now(), published_at = CASE WHEN $3 = 'publishing' AND published_at IS NULL THEN now() ELSE published_at END WHERE id = $1 AND workspace_id = $2", id, workspace_id, status, ) .execute(pool) .await?; Ok(()) } pub async fn attach_agent( pool: &PgPool, topic_id: Uuid, agent_id: Uuid, role_slot: Option<&str>, ) -> Result<(), DbError> { sqlx::query!( "INSERT INTO research_topic_agents (topic_id, agent_id, role_slot) VALUES ($1, $2, $3) ON CONFLICT (topic_id, agent_id) DO UPDATE SET role_slot = EXCLUDED.role_slot", topic_id, agent_id, role_slot, ) .execute(pool) .await?; Ok(()) } pub async fn detach_agent(pool: &PgPool, topic_id: Uuid, agent_id: Uuid) -> Result<(), DbError> { sqlx::query!( "DELETE FROM research_topic_agents WHERE topic_id = $1 AND agent_id = $2", topic_id, agent_id, ) .execute(pool) .await?; Ok(()) } pub async fn agents(pool: &PgPool, topic_id: Uuid) -> Result, DbError> { let rows = sqlx::query!( "SELECT agent_id, role_slot FROM research_topic_agents WHERE topic_id = $1", topic_id, ) .fetch_all(pool) .await?; Ok(rows .into_iter() .map(|r| AgentSlot { agent_id: r.agent_id, role_slot: r.role_slot, }) .collect()) }