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
+1
View File
@@ -15,6 +15,7 @@ pub mod node_tools;
pub mod nodes;
pub mod orgs;
pub mod outbox;
pub mod research_topics;
pub mod routine_runs;
pub mod routines;
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())
}