research: backend routes + repo + wizard refine (behind /api/research)
ci / gates (push) Successful in 7s
ci / rust (push) Failing after 11s
ci / frontend (push) Successful in 27s
ci / e2e (push) Has been skipped
ci / publish (push) Has been skipped

Second commit of the Research + Loops arc. Builds on 0030 by lighting up
the container CRUD, the agent-slot attach/detach, the standby→processing
transition, and the wizard's one-shot LLM refine.

  GET    /api/research                     list workspace's topics
  POST   /api/research                     create (accepts wizard output)
  GET    /api/research/:id                 detail (topic + attached agents)
  PATCH  /api/research/:id                 update non-status fields
  POST   /api/research/:id/agents          attach agent (idempotent)
  DELETE /api/research/:id/agents/:agent   detach
  POST   /api/research/:id/start           standby → processing
  POST   /api/research/wizard/refine       one-shot LLM refine

The refine endpoint accumulates the workspace's default LLM provider's
stream into a JSON object (`{title, description}`) with the tight system
prompt at the top of the module. Same provider routing as agent runs
(via runtime.provider()), so a workspace already using GLM/Kimi gets it
for free.

State machine's remaining transitions (processing → reviewing on last
run_completed; reviewing → publishing via approvals gate) land with the
orchestrator hookup + approvals extension. Publish approval and loops
are separate commits still to come.

Adds cm-llm as a direct cm-api dep (previously only pulled transitively
via cm-runtime) so the refine endpoint can build a ChatRequest. Uses
sqlx::query! for compile-time verification; .sqlx cache generated on
morpheus against a fresh migrated DB.
This commit is contained in:
Omar Sobh
2026-07-06 04:27:17 -07:00
parent 258113e4d8
commit fb047879c2
14 changed files with 817 additions and 0 deletions
@@ -0,0 +1,19 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO research_topics\n (id, workspace_id, title, description, outcome_kind, status, created_by)\n VALUES ($1, $2, $3, $4, $5, 'standby', $6)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Uuid",
"Text",
"Text",
"Text",
"Uuid"
]
},
"nullable": []
},
"hash": "040eedfd8cf97e0dbb5719fd150f961b938f3792d764d3e4f724c6da48294acd"
}
@@ -0,0 +1,76 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id, workspace_id, title, description, outcome_kind, status,\n created_by, created_at, updated_at, published_at\n FROM research_topics\n WHERE workspace_id = $1\n ORDER BY updated_at DESC",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "workspace_id",
"type_info": "Uuid"
},
{
"ordinal": 2,
"name": "title",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "description",
"type_info": "Text"
},
{
"ordinal": 4,
"name": "outcome_kind",
"type_info": "Text"
},
{
"ordinal": 5,
"name": "status",
"type_info": "Text"
},
{
"ordinal": 6,
"name": "created_by",
"type_info": "Uuid"
},
{
"ordinal": 7,
"name": "created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 8,
"name": "updated_at",
"type_info": "Timestamptz"
},
{
"ordinal": 9,
"name": "published_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
false,
false,
false,
false,
false,
false,
false,
false,
false,
true
]
},
"hash": "080aa9dc0fe2e51c239e721dab1c33cd6ad31e679484e277a954c0781fdaa342"
}
@@ -0,0 +1,18 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE research_topics\n SET title = $3, description = $4, outcome_kind = $5, updated_at = now()\n WHERE id = $1 AND workspace_id = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Uuid",
"Text",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "2541fc9bef45124c48cdaa60c070e9ee7ade0d8c7859bbdd81068fb8e28e4a1c"
}
@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE research_topics\n SET status = $3,\n updated_at = now(),\n published_at = CASE\n WHEN $3 = 'publishing' AND published_at IS NULL THEN now()\n ELSE published_at\n END\n WHERE id = $1 AND workspace_id = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Uuid",
"Text"
]
},
"nullable": []
},
"hash": "28aff26774a5f6a91adfc56252062b5a1cadb42d498276fc7018a0d0f0fe98d5"
}
@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO research_topic_agents (topic_id, agent_id, role_slot)\n VALUES ($1, $2, $3)\n ON CONFLICT (topic_id, agent_id) DO UPDATE\n SET role_slot = EXCLUDED.role_slot",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Uuid",
"Text"
]
},
"nullable": []
},
"hash": "494d34241ef7c27c739ca5cc11114ada1a86bdcc30fe04ac5e7e58416d317679"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM research_topic_agents WHERE topic_id = $1 AND agent_id = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Uuid"
]
},
"nullable": []
},
"hash": "5f8550702d365534f8778f1109b8a0d504e3b724e4d38d247de6ac2cbdd916e3"
}
@@ -0,0 +1,28 @@
{
"db_name": "PostgreSQL",
"query": "SELECT agent_id, role_slot FROM research_topic_agents WHERE topic_id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "agent_id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "role_slot",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
false,
true
]
},
"hash": "c03f113360a03744f2e448979385c1348f6c49f88263e4583be71c527fba0ccc"
}
@@ -0,0 +1,77 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id, workspace_id, title, description, outcome_kind, status,\n created_by, created_at, updated_at, published_at\n FROM research_topics\n WHERE id = $1 AND workspace_id = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "workspace_id",
"type_info": "Uuid"
},
{
"ordinal": 2,
"name": "title",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "description",
"type_info": "Text"
},
{
"ordinal": 4,
"name": "outcome_kind",
"type_info": "Text"
},
{
"ordinal": 5,
"name": "status",
"type_info": "Text"
},
{
"ordinal": 6,
"name": "created_by",
"type_info": "Uuid"
},
{
"ordinal": 7,
"name": "created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 8,
"name": "updated_at",
"type_info": "Timestamptz"
},
{
"ordinal": 9,
"name": "published_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Uuid",
"Uuid"
]
},
"nullable": [
false,
false,
false,
false,
false,
false,
false,
false,
false,
true
]
},
"hash": "eecbf1bc6a6ed59635ea26dffbba8ed014e58a6e0da19576b01912936317ed1a"
}
+1
View File
@@ -24,6 +24,7 @@ cm-brain = { path = "../cm-brain" }
cm-config = { path = "../cm-config" } cm-config = { path = "../cm-config" }
cm-db = { path = "../cm-db" } cm-db = { path = "../cm-db" }
cm-domain = { path = "../cm-domain" } cm-domain = { path = "../cm-domain" }
cm-llm = { path = "../cm-llm" }
cm-orchestrator = { path = "../cm-orchestrator", features = ["provider"] } cm-orchestrator = { path = "../cm-orchestrator", features = ["provider"] }
cm-runtime = { path = "../cm-runtime" } cm-runtime = { path = "../cm-runtime" }
cm-sandbox = { path = "../cm-sandbox" } cm-sandbox = { path = "../cm-sandbox" }
+24
View File
@@ -379,6 +379,30 @@ pub fn router(state: AppState) -> Router {
get(routes::orgs::get_org).delete(routes::orgs::delete_org), get(routes::orgs::get_org).delete(routes::orgs::delete_org),
) )
.route("/api/orgs/{id}/run", post(routes::orgs::run_org)) .route("/api/orgs/{id}/run", post(routes::orgs::run_org))
.route(
"/api/research",
get(routes::research::list_topics).post(routes::research::create_topic),
)
.route(
"/api/research/{id}",
get(routes::research::get_topic).patch(routes::research::patch_topic),
)
.route(
"/api/research/{id}/agents",
post(routes::research::attach_agent),
)
.route(
"/api/research/{id}/agents/{agent_id}",
axum::routing::delete(routes::research::detach_agent),
)
.route(
"/api/research/{id}/start",
post(routes::research::start_topic),
)
.route(
"/api/research/wizard/refine",
post(routes::research::refine_wizard),
)
.route("/api/structure/stats", get(routes::structure::stats)) .route("/api/structure/stats", get(routes::structure::stats))
.route("/api/structure/{level}/{id}", get(routes::structure::node)) .route("/api/structure/{level}/{id}", get(routes::structure::node))
.route("/api/topology-runs", get(routes::topology::list_runs)) .route("/api/topology-runs", get(routes::topology::list_runs))
+1
View File
@@ -16,6 +16,7 @@ pub mod nodes;
pub mod oauth; pub mod oauth;
pub mod orgs; pub mod orgs;
pub mod planner; pub mod planner;
pub mod research;
pub mod routines; pub mod routines;
pub mod sessions; pub mod sessions;
pub mod skills; pub mod skills;
+333
View File
@@ -0,0 +1,333 @@
//! Research topic endpoints — the container CRUD + the wizard's one-shot LLM
//! refine call. The state machine's advancing transitions (processing →
//! reviewing, reviewing → publishing) land in later commits alongside the
//! orchestrator + approval-gate wiring. What ships here:
//!
//! GET /api/research list workspace's topics
//! POST /api/research create (accepts wizard output)
//! GET /api/research/:id detail (topic + attached agents)
//! PATCH /api/research/:id update non-status fields
//! POST /api/research/:id/agents attach agent (idempotent)
//! DELETE /api/research/:id/agents/:agent detach
//! POST /api/research/:id/start standby → processing
//! POST /api/research/wizard/refine one-shot LLM refine helper
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::Json;
use cm_llm::{ChatMessage, ChatRequest, ChatRole, ContentPart, LlmEvent};
use futures::StreamExt;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::{ApiError, AppState, Authed};
const VALID_OUTCOMES: &[&str] = &["spec", "prod_plan", "roadmap", "paper"];
fn check_outcome(kind: &str) -> Result<(), ApiError> {
if VALID_OUTCOMES.contains(&kind) {
Ok(())
} else {
Err(ApiError::BadRequest)
}
}
#[derive(Deserialize)]
pub struct CreateTopicRequest {
pub title: String,
pub description: String,
pub outcome_kind: String,
#[serde(default)]
pub agents: Vec<AgentSlotInput>,
}
#[derive(Deserialize)]
pub struct AgentSlotInput {
pub agent_id: Uuid,
#[serde(default)]
pub role_slot: Option<String>,
}
#[derive(Serialize)]
pub struct TopicCreated {
pub id: Uuid,
}
/// `POST /api/research` — create a topic in `standby` and attach any agents
/// the wizard captured. Attachments are idempotent so a client retry after
/// a partial failure is safe.
pub async fn create_topic(
State(state): State<AppState>,
Authed(user): Authed,
Json(body): Json<CreateTopicRequest>,
) -> Result<(StatusCode, Json<TopicCreated>), ApiError> {
if body.title.trim().is_empty() || body.description.trim().is_empty() {
return Err(ApiError::BadRequest);
}
check_outcome(&body.outcome_kind)?;
// Ownership check before any writes: every agent must be in the caller's
// workspace. Refuses to leak "agent exists" if it isn't visible.
for slot in &body.agents {
let agent = cm_db::repo::agents::get(&state.pool, slot.agent_id.into()).await?;
if agent.workspace_id.as_uuid() != user.workspace_id.as_uuid() {
return Err(ApiError::NotFound);
}
}
let id = cm_db::repo::research_topics::create(
&state.pool,
user.workspace_id.as_uuid(),
body.title.trim(),
body.description.trim(),
&body.outcome_kind,
user.user_id.as_uuid(),
)
.await?;
for slot in body.agents {
cm_db::repo::research_topics::attach_agent(
&state.pool,
id,
slot.agent_id,
slot.role_slot.as_deref(),
)
.await?;
}
Ok((StatusCode::CREATED, Json(TopicCreated { id })))
}
#[derive(Serialize)]
pub struct TopicListItem {
pub id: Uuid,
pub title: String,
pub outcome_kind: String,
pub status: String,
pub updated_at: String,
}
pub async fn list_topics(
State(state): State<AppState>,
Authed(user): Authed,
) -> Result<Json<Vec<TopicListItem>>, ApiError> {
let rows =
cm_db::repo::research_topics::list(&state.pool, user.workspace_id.as_uuid()).await?;
Ok(Json(
rows.into_iter()
.map(|t| TopicListItem {
id: t.id,
title: t.title,
outcome_kind: t.outcome_kind,
status: t.status,
updated_at: t
.updated_at
.format(&time::format_description::well_known::Rfc3339)
.unwrap_or_default(),
})
.collect(),
))
}
#[derive(Serialize)]
pub struct TopicDetail {
#[serde(flatten)]
pub topic: cm_db::repo::research_topics::ResearchTopic,
pub agents: Vec<cm_db::repo::research_topics::AgentSlot>,
}
pub async fn get_topic(
State(state): State<AppState>,
Authed(user): Authed,
Path(id): Path<Uuid>,
) -> Result<Json<TopicDetail>, ApiError> {
let topic = cm_db::repo::research_topics::get(&state.pool, id, user.workspace_id.as_uuid())
.await?
.ok_or(ApiError::NotFound)?;
let agents = cm_db::repo::research_topics::agents(&state.pool, id).await?;
Ok(Json(TopicDetail { topic, agents }))
}
#[derive(Deserialize)]
pub struct UpdateTopicRequest {
pub title: String,
pub description: String,
pub outcome_kind: String,
}
pub async fn patch_topic(
State(state): State<AppState>,
Authed(user): Authed,
Path(id): Path<Uuid>,
Json(body): Json<UpdateTopicRequest>,
) -> Result<StatusCode, ApiError> {
if body.title.trim().is_empty() || body.description.trim().is_empty() {
return Err(ApiError::BadRequest);
}
check_outcome(&body.outcome_kind)?;
cm_db::repo::research_topics::get(&state.pool, id, user.workspace_id.as_uuid())
.await?
.ok_or(ApiError::NotFound)?;
cm_db::repo::research_topics::update_fields(
&state.pool,
id,
user.workspace_id.as_uuid(),
body.title.trim(),
body.description.trim(),
&body.outcome_kind,
)
.await?;
Ok(StatusCode::NO_CONTENT)
}
pub async fn attach_agent(
State(state): State<AppState>,
Authed(user): Authed,
Path(id): Path<Uuid>,
Json(body): Json<AgentSlotInput>,
) -> Result<StatusCode, ApiError> {
cm_db::repo::research_topics::get(&state.pool, id, user.workspace_id.as_uuid())
.await?
.ok_or(ApiError::NotFound)?;
let agent = cm_db::repo::agents::get(&state.pool, body.agent_id.into()).await?;
if agent.workspace_id.as_uuid() != user.workspace_id.as_uuid() {
return Err(ApiError::NotFound);
}
cm_db::repo::research_topics::attach_agent(
&state.pool,
id,
body.agent_id,
body.role_slot.as_deref(),
)
.await?;
Ok(StatusCode::NO_CONTENT)
}
pub async fn detach_agent(
State(state): State<AppState>,
Authed(user): Authed,
Path((id, agent_id)): Path<(Uuid, Uuid)>,
) -> Result<StatusCode, ApiError> {
cm_db::repo::research_topics::get(&state.pool, id, user.workspace_id.as_uuid())
.await?
.ok_or(ApiError::NotFound)?;
cm_db::repo::research_topics::detach_agent(&state.pool, id, agent_id).await?;
Ok(StatusCode::NO_CONTENT)
}
/// `POST /api/research/:id/start` — flips status from `standby` to
/// `processing`. Only valid transition on this endpoint; the reviewing +
/// publishing hops come from the orchestrator and the publish approval gate.
pub async fn start_topic(
State(state): State<AppState>,
Authed(user): Authed,
Path(id): Path<Uuid>,
) -> Result<StatusCode, ApiError> {
let topic = cm_db::repo::research_topics::get(&state.pool, id, user.workspace_id.as_uuid())
.await?
.ok_or(ApiError::NotFound)?;
if topic.status != "standby" {
return Err(ApiError::Conflict);
}
cm_db::repo::research_topics::set_status(
&state.pool,
id,
user.workspace_id.as_uuid(),
"processing",
)
.await?;
Ok(StatusCode::NO_CONTENT)
}
// ── wizard refine ──────────────────────────────────────────────────────────
#[derive(Deserialize)]
pub struct RefineRequest {
/// The user's raw topic prompt from Step 1 of the wizard.
pub prompt: String,
/// Optional prior refined draft, for iterative refinement rounds.
#[serde(default)]
pub prior_description: Option<String>,
pub outcome_kind: String,
}
#[derive(Serialize)]
pub struct RefineResponse {
/// The refined description (structured markdown) the wizard shows in
/// Step 2 for the user to accept/edit before Create.
pub description: String,
pub suggested_title: String,
}
const REFINE_SYSTEM: &str = "You are a research-scoping assistant. Given a raw topic prompt and \
a target outcome kind, produce a tightly scoped research description as markdown with these \
sections in this exact order: `## Framing` (2-3 sentences), `## Key questions` (3-5 bullets), \
`## Success criteria` (3-4 bullets tied to the outcome kind). Do NOT include any preamble, \
disclaimer, or commentary outside the markdown. Also produce a short punchy title (<=8 words). \
Return a single JSON object: {\"title\": \"…\", \"description\": \"…\"} with no code fences and \
no additional keys.";
/// `POST /api/research/wizard/refine` — one-shot LLM refine. Accumulates the
/// streaming provider into a single string and parses it. Uses the same
/// default model as agent runs (workspace's `clawmates.toml` provider).
pub async fn refine_wizard(
State(state): State<AppState>,
Authed(_user): Authed,
Json(body): Json<RefineRequest>,
) -> Result<Json<RefineResponse>, ApiError> {
if body.prompt.trim().is_empty() {
return Err(ApiError::BadRequest);
}
check_outcome(&body.outcome_kind)?;
let user_message = match body.prior_description.as_deref() {
Some(prior) if !prior.trim().is_empty() => format!(
"Outcome kind: {}\n\nCurrent topic prompt:\n{}\n\nPrior refined draft:\n{}\n\nRefine \
further, keeping the same section structure.",
body.outcome_kind, body.prompt, prior,
),
_ => format!(
"Outcome kind: {}\n\nTopic prompt:\n{}",
body.outcome_kind, body.prompt,
),
};
let request = ChatRequest {
system: REFINE_SYSTEM.into(),
messages: vec![ChatMessage {
role: ChatRole::User,
parts: vec![ContentPart::Text {
text: user_message,
}],
}],
tools: Vec::new(),
model: state.runtime.model().to_string(),
max_tokens: 2048,
web_search: false,
};
let provider = state.runtime.provider();
let mut stream = provider.stream(request).await.map_err(|_| ApiError::Internal)?;
let mut buf = String::new();
while let Some(event) = stream.next().await {
match event.map_err(|_| ApiError::Internal)? {
LlmEvent::TextDelta(delta) => buf.push_str(&delta),
LlmEvent::Stop(_) => break,
_ => {}
}
}
#[derive(Deserialize)]
struct Parsed {
title: String,
description: String,
}
let parsed: Parsed = serde_json::from_str(buf.trim()).map_err(|_| ApiError::Internal)?;
if parsed.title.trim().is_empty() || parsed.description.trim().is_empty() {
return Err(ApiError::Internal);
}
Ok(Json(RefineResponse {
description: parsed.description,
suggested_title: parsed.title,
}))
}
+1
View File
@@ -15,6 +15,7 @@ pub mod node_tools;
pub mod nodes; pub mod nodes;
pub mod orgs; pub mod orgs;
pub mod outbox; pub mod outbox;
pub mod research_topics;
pub mod routine_runs; pub mod routine_runs;
pub mod routines; pub mod routines;
pub mod run_events; pub mod run_events;
+192
View File
@@ -0,0 +1,192 @@
//! 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<OffsetDateTime>,
}
#[derive(Debug, Clone, Serialize)]
pub struct AgentSlot {
pub agent_id: Uuid,
pub role_slot: Option<String>,
}
/// 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,
created_by: Uuid,
) -> Result<Uuid, DbError> {
let id = Uuid::now_v7();
sqlx::query!(
"INSERT INTO research_topics
(id, workspace_id, title, description, outcome_kind, status, created_by)
VALUES ($1, $2, $3, $4, $5, 'standby', $6)",
id,
workspace_id,
title,
description,
outcome_kind,
created_by,
)
.execute(pool)
.await?;
Ok(id)
}
/// Workspace's topics, newest-updated first.
pub async fn list(pool: &PgPool, workspace_id: Uuid) -> Result<Vec<ResearchTopic>, DbError> {
let rows = sqlx::query_as!(
ResearchTopic,
"SELECT id, workspace_id, title, description, outcome_kind, status,
created_by, created_at, updated_at, published_at
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<Option<ResearchTopic>, DbError> {
let row = sqlx::query_as!(
ResearchTopic,
"SELECT id, workspace_id, title, description, outcome_kind, status,
created_by, created_at, updated_at, published_at
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<Vec<AgentSlot>, 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())
}