Files
clawmates/crates/cm-api/src/routes/research.rs
T
Omar Sobh 0b7f247b0e
ci / gates (push) Successful in 5s
ci / frontend (push) Successful in 26s
ci / rust (push) Successful in 4m4s
ci / e2e (push) Skipped
ci / publish (push) Successful in 2m36s
wizard: 'fresh coding team' picker for paired coding loop
Second slice of the per-loop-team arc. The paired-coding-loop checkbox
in ResearchWizard step 6 now exposes a two-option picker:

  ⦿ Provision a dedicated coding team (default when the loop is on)
     — fresh 'Coding · <topic>' team row, risk_profile =
       coding_readwrite, clawmates_door in mcp_bundles. Loop's
       team_id is bound at wizard-submit time.
  ○ Reuse the research team (legacy) — no team_id bound; coding
     iterations spawn against the research topic's container.

Frontend
- New codingTeamMode state, radio picker rendered under the checkbox.
- research.ts createTopic body gains paired_coding_team_mode?: 'fresh'|
  'reuse'.

Backend
- CreateTopicRequest gains paired_coding_team_mode: Option<String>.
- materialize_topic_loops takes it through and, when 'fresh', calls
  the new provision_fresh_coding_team helper — inserts a teams row
  via the existing insert_team_with_lifecycle (pipeline kind, same
  graph as the loop), sets its runtime-config via
  set_team_runtime_config, then binds loop.team_id.
- All operations best-effort with stderr logging — a team-provision
  failure leaves the loop functional under the legacy fallback.

Not shipped in this slice (deferred to runtime hookup slice):
- research_container::spawn keyed on team_id → per-team container
- Config template rewrite injecting the team's risk_profile
- Migration of existing paired loops onto their own teams

The plumbing lands now so the wizard's intent is recorded; the
runtime honors it in the next PR.
2026-07-16 20:49:54 -07:00

1276 lines
49 KiB
Rust

//! Research topic endpoints — CRUD, state-machine transitions, the publish
//! approval gate, and the wizard's one-shot LLM refine call. Reads for the
//! publish gate share this module because they're semantically the topic's
//! terminal action.
//!
//! GET /api/research list workspace's topics
//! POST /api/research create (accepts wizard output)
//! GET /api/research/:id detail (topic + 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/:id/submit-review processing → reviewing
//! POST /api/research/:id/request-publish create pending publish approval
//! GET /api/research/publish-approvals list workspace's pending approvals
//! POST /api/research/publish-approvals/:id/approve reviewing → publishing (Owner only)
//! POST /api/research/publish-approvals/:id/reject stays in reviewing (Owner only)
//! POST /api/research/wizard/refine one-shot LLM refine helper
use axum::extract::{Path, State};
use axum::http::{header, StatusCode};
use axum::response::IntoResponse;
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", "integrations"];
/// The canonical deliverable shape for an outcome kind, injected into the
/// coordinator prompt so runs actually produce the promised structure —
/// prior to this the kind was a bare label with no template. The block
/// lands under an "ARTIFACT SHAPE" header in the coordinator task.
fn deliverable_template(kind: &str) -> &'static str {
match kind {
"spec" => {
"\
Deliver a technical specification with exactly these sections in order:\n\
## Problem\n\
## Goals + non-goals\n\
## Interfaces (types, function signatures, protocols)\n\
## Data model\n\
## Alternatives considered (with rejection rationale)\n\
## Acceptance criteria (measurable)\n"
}
"prod_plan" => {
"\
Deliver a prioritized execution plan with exactly these sections in order:\n\
## Objective\n\
## Success metrics\n\
## Workstreams (P0 → P1 → P2, each with owner + estimate)\n\
## Milestones (dated where possible)\n\
## Risks + dependencies\n\
## Definition of done\n"
}
"roadmap" => {
"\
Deliver a horizon roadmap with exactly these sections in order:\n\
## Vision\n\
## Now (0-6 weeks) — bulleted, dated\n\
## Next (6-16 weeks) — themes + expected outputs\n\
## Later (16+ weeks) — bets + open questions\n\
## Cross-cutting concerns\n"
}
"paper" => {
"\
Deliver a short scientific-style paper with exactly these sections in order:\n\
## Abstract (150-200 words)\n\
## Background + related work (with citations)\n\
## Method\n\
## Findings\n\
## Discussion + limitations\n\
## References\n"
}
"integrations" => {
"\
Deliver a prioritized MENU of concrete integrations we can implement in the \
BOUND REPO, each grounded in one or more published papers AND in real files/\
modules of this repo. Every integration is self-contained so a downstream \
coding loop can execute exactly one item per iteration.\n\
\n\
Required top matter:\n\
# Integration Plan · <repo slug>\n\
## Executive summary\n\
· Focus areas: <perf | stability | security | provenance — one or more>\n\
· Papers surveyed: <n>\n\
· Recommended integrations: <n> (P0: <n>, P1: <n>, P2: <n>)\n\
\n\
Then ## Integrations, and for EACH candidate emit an item with this exact \
shape and stable id (INT-01, INT-02, ...) so a loop can address items by id:\n\
\n\
### INT-<NN> · <Name> · P0|P1|P2 · <focus area>\n\
**What it is**: 1-3 sentence description of the technique.\n\
**Source(s)**: paper title · authors · year · arXiv/DOI (one line per paper).\n\
**How we would accomplish it**: numbered concrete steps — reference the \
paper's algorithm/section AND the repo's actual file paths.\n\
**Where in the architecture**: bulleted list of files/modules touched; \
mark NEW modules explicitly.\n\
**Prerequisites**: other INT-ids that must land first (or 'none').\n\
**Effort**: S / M / L with a one-line breakdown.\n\
**Risk**: low / med / high + one specific concern.\n\
**Testing**: existing suites to exercise + new tests to add.\n\
**Rollback**: feature flag name or revert plan.\n\
**Acceptance criteria**: bulleted, measurable, tied to the focus area.\n\
\n\
Ordering rules: sort by priority (P0 first), and within a priority sort so \
prerequisites come before dependents. Cite every claim with either a paper \
reference or a repo file path — never fabricate paths or citations.\n"
}
_ => "",
}
}
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,
/// hub_spoke | pipeline | hierarchical | star_moe. Defaults to hub_spoke.
#[serde(default)]
pub topology_kind: Option<String>,
#[serde(default)]
pub agents: Vec<AgentSlotInput>,
/// The workspace repo the wizard bound to this topic. Only `repo_id` is
/// authoritative; the rest is denormalized display data the wizard sent
/// for its own UI and is ignored here.
#[serde(default)]
pub repo: Option<TopicRepoRef>,
/// D1 fold — when set, the handler also materializes a
/// kind='research' loop bound to this topic that owns the runs.
/// Omit for backwards-compat (topic behaves like the legacy
/// one-shot flow).
#[serde(default)]
pub schedule: Option<TopicSchedule>,
/// D1 fold — when true (and schedule is set), also create a
/// kind='exec' loop bound to this topic with on_artifact_update.
#[serde(default)]
pub create_paired_coding_loop: bool,
/// 0045 fold — team disposition for the paired coding loop:
/// Some("fresh") — provision a dedicated coding team with
/// coding_readwrite risk profile (recommended)
/// Some("reuse") — attach the coding loop to the research team
/// (legacy behavior, both share one container)
/// None — treated as "reuse" for backwards compat.
#[serde(default)]
pub paired_coding_team_mode: Option<String>,
}
// The wizard sends a denormalized display object for its own UI. Only
// repo_id is authoritative; the rest is present so serde deserializes the
// full body cleanly (and future callers can piggyback additional
// metadata) even though start_topic ignores it.
#[derive(Deserialize)]
#[allow(dead_code)]
pub struct TopicRepoRef {
pub repo_id: Uuid,
#[serde(default)]
pub connection_id: Option<Uuid>,
#[serde(default)]
pub provider: Option<String>,
#[serde(default)]
pub owner: Option<String>,
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub default_branch: Option<String>,
}
/// Topology kinds the wizard exposes for research. Every string here must
/// also be a valid `cm_topology::TopologyKind` — start_topic passes it
/// through to the builder verbatim.
const VALID_TOPOLOGY_KINDS: &[&str] = &["hub_spoke", "pipeline", "hierarchical", "star_moe"];
/// Assemble the task prompt fed to the graph's head node. Shape branches on
/// topology so the head's instructions actually match how the graph will
/// run: hub_spoke → central coordinator delegates + synthesizes; pipeline →
/// stage-1 opens and each stage owns its handoff, the last stage produces
/// the artifact; hierarchical → root plans, children work parallel, root
/// synthesizes; star_moe → router classifies + dispatches to the domain
/// expert best-suited to each subtask.
fn build_coordinator_task(
title: &str,
outcome: &str,
description: &str,
topo: &cm_topology::TopologyKind,
roster: &str,
repo: Option<&RepoContext>,
) -> String {
use cm_topology::TopologyKind::*;
let repo_block = repo
.map(|r| {
format!(
"REPO (cloned on the API host, shallow):\n\
· slug: {slug}\n\
· path: {path}\n\
· branch: {branch}\n\
· files sampled ({shown} of {total}):\n{tree}\n\n",
slug = r.slug,
path = r.path,
branch = r.branch,
shown = r.shown,
total = r.total_files,
tree = r.tree_preview,
)
})
.unwrap_or_default();
let repo_guidance = if repo.is_some() {
"USING THE REPO:\n\
The repo above is real and already checked out. Ground every claim, \
proposal, and acceptance-criterion in a concrete file or module you \
reference by path. Enumerate the files you inspected in your final \
artifact so a reviewer can walk your reasoning. If a spoke lacks \
file access, describe the module + interface you want them to \
reason about — never fabricate paths.\n\n"
} else {
""
};
let template = deliverable_template(outcome);
let shape_block = if template.is_empty() {
String::new()
} else {
format!("ARTIFACT SHAPE (the final synthesis MUST match this):\n{template}\n")
};
let framing = format!(
"RESEARCH TOPIC: {title}\n\
OUTCOME KIND: {outcome} (spec / prod_plan / roadmap / paper / integrations)\n\n\
DESCRIPTION:\n{description}\n\n\
{repo_block}\
{repo_guidance}\
{shape_block}\
TEAM:\n{roster}\n\n"
);
let body = match topo {
HubSpoke => {
"SHAPE: hub_spoke. You are the central coordinator (hub). \
Every teammate is a spoke you can address per turn.\n\n\
YOUR JOB:\n\
1. Break the topic into concrete sub-tasks and assign each to the best-fit spoke.\n\
2. Delegate turn-by-turn: each spoke's response feeds your next dispatch.\n\
3. Synthesize their outputs into a single artifact that satisfies the description.\n\
4. Cite each spoke's contribution where it lands in the final document.\n\
5. Structure the final document with clear sections — problem, evidence, \
proposal, and measurable acceptance criteria — for every improvement area \
implied by the description."
}
Pipeline => {
"SHAPE: pipeline. You are stage 1. Each teammate is the next \
stage in a linear handoff — your output is the next stage's only input, and so \
on until the last stage produces the final artifact.\n\n\
YOUR JOB (stage 1):\n\
1. Do YOUR stage's specific work as described in your role.\n\
2. Structure your handoff so the next stage can act on it directly — cite \
sources, name the units you produced, and enumerate anything the next stage \
must inspect.\n\
3. Do NOT try to write the final artifact yourself; that's the last stage's job.\n\
4. Keep the topic's outcome_kind in mind — the pipeline will produce a single \
document of that shape when the last stage synthesizes.\n\n\
LAST-STAGE INSTRUCTION (propagate this in your handoff):\n\
The final stage MUST emit the complete artifact as a single markdown document, \
structured by section, with measurable acceptance criteria for every \
recommendation and inline citations to any evidence collected upstream."
}
Hierarchical => {
"SHAPE: hierarchical. You are the root. Your direct children \
work in parallel with your plan as their context, then you synthesize their \
outputs.\n\n\
YOUR JOB:\n\
1. Decompose the topic into distinct sub-problems, one per child, chosen so \
they can run in parallel without cross-dependencies.\n\
2. Fan out: state the sub-problem, constraints, and expected output shape for \
each child's independent work.\n\
3. Collect their outputs. Synthesize into a single artifact structured by \
section, resolving any conflicts explicitly.\n\
4. Attribute each section to the contributing child."
}
StarMoe => {
"SHAPE: star_moe (mixture-of-experts). You are the router. Each \
teammate is a domain expert. Route subtasks to whichever expert best matches \
the domain of the subtask.\n\n\
YOUR JOB:\n\
1. Analyze the topic's description and enumerate the distinct domains it touches.\n\
2. For each domain, address the best-fit expert (by role / job title) with a \
scoped question. Never broadcast — routing beats fan-out here.\n\
3. Collect expert answers and produce a single artifact structured by section, \
one per domain, citing the routed expert."
}
_ => {
"Coordinate your teammates to produce a single artifact satisfying \
the description."
}
};
format!("{framing}{body}")
}
// RepoContext, research_workspace_root, prepare_topic_runtime,
// ensure_repo_workspace, TopicSchedule, and materialize_topic_loops
// moved to `research_setup.rs` to keep this file under the 1250-line
// budget. Import re-uses below.
use crate::routes::research_setup::{
ensure_repo_workspace, materialize_topic_loops, research_workspace_root, RepoContext,
TopicSchedule,
};
#[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)?;
let topology_kind = body.topology_kind.as_deref().unwrap_or("hub_spoke");
if !VALID_TOPOLOGY_KINDS.contains(&topology_kind) {
return Err(ApiError::BadRequest);
}
// Empty-roster gate: a workspace with zero agents cannot host research.
// The frontend already disables the create button; this closes the
// direct-POST hole so we never materialize an unstaffed topic.
if cm_db::repo::agents::count_active(&state.pool, user.workspace_id).await? == 0 {
return Err(ApiError::Conflict);
}
// 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);
}
}
// Same ownership check for the repo binding.
let repo_id = if let Some(r) = &body.repo {
cm_db::repo::repos::get(&state.pool, r.repo_id, user.workspace_id).await?;
Some(r.repo_id)
} else {
None
};
let id = cm_db::repo::research_topics::create(
&state.pool,
cm_db::repo::research_topics::NewTopic {
workspace_id: user.workspace_id.as_uuid(),
title: body.title.trim(),
description: body.description.trim(),
outcome_kind: &body.outcome_kind,
topology_kind,
repo_id,
created_by: 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?;
}
// D1 fold — when the wizard picked a schedule, materialize the
// paired kind='research' loop that owns runs. Best-effort per
// loop: a loop-create failure logs but the topic still lands so
// the user can retry from the sidebar.
if let Some(sched) = &body.schedule {
materialize_topic_loops(
&state.pool,
user.workspace_id.as_uuid(),
user.user_id.as_uuid(),
id,
&body.title,
&sched.mode,
body.create_paired_coding_loop,
body.paired_coding_team_mode.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,
/// queued + running topology_runs bound to this topic.
pub runs_in_flight: i64,
/// Failed runs since the last successful run (or all-time if
/// none). A positive value combined with `runs_in_flight == 0`
/// and `status == "processing"` is the errored-but-not-terminal
/// state — frontend swaps the spinner for an error icon and
/// offers a rerun.
pub runs_failed: i64,
}
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?;
let ids: Vec<Uuid> = rows.iter().map(|t| t.id).collect();
let counts = cm_db::repo::topology_runs::run_counts_by_research_topic(&state.pool, &ids)
.await
.unwrap_or_default();
let mut count_by: std::collections::HashMap<Uuid, (i64, i64)> = counts
.into_iter()
.map(|(id, in_flight, failed)| (id, (in_flight, failed)))
.collect();
Ok(Json(
rows.into_iter()
.map(|t| {
let (runs_in_flight, runs_failed) = count_by.remove(&t.id).unwrap_or((0, 0));
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(),
runs_in_flight,
runs_failed,
}
})
.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>,
/// True when a publish approval is already pending for this topic.
/// Lets the frontend hide the "Request publish" button and show
/// "Awaiting reviewer approval" instead — avoids the 409 the user
/// gets from double-clicking.
pub has_pending_publish_request: bool,
/// The most recent `research_outcomes` row for this topic — the draft
/// the pipeline produced. Present once a run has completed; the canvas
/// renders `body_md` in place of `description` when in `reviewing` and
/// beyond so reviewers see what actually needs approval.
#[serde(skip_serializing_if = "Option::is_none")]
pub latest_outcome: Option<cm_db::repo::research_outcomes::Outcome>,
/// queued + running topology_runs bound to this topic. > 0 means the
/// pipeline is still working — the canvas shows a running badge with a
/// spinner and hides the manual "Submit for review" button, which is
/// only offered when this is 0 (as an escape hatch for stalled runs).
pub runs_in_flight: i64,
/// The wizard-materialized research loop that owns this topic's runs
/// (D1 fold). Populated when a kind='research' loop exists with
/// source_research_topic_id = this topic. Frontend uses this to
/// hide the classic "Start research" button and instead show a
/// "Managed by scheduled loop" strip.
#[serde(skip_serializing_if = "Option::is_none")]
pub managed_by_loop: Option<ManagedLoop>,
}
#[derive(Serialize)]
pub struct ManagedLoop {
pub loop_id: Uuid,
pub title: String,
pub enabled: bool,
pub next_fire_at: Option<time::OffsetDateTime>,
pub last_run_id: Option<Uuid>,
/// Human-readable schedule summary derived from the loop's
/// triggers jsonb — e.g. "Nightly (cron: 0 3 * * *)", "Manual",
/// "Once at create". Convenience for the canvas strip.
pub schedule_summary: String,
}
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?;
let has_pending_publish_request =
cm_db::repo::research_publish_approvals::pending_for_topic(&state.pool, id)
.await?
.is_some();
let latest_outcome = cm_db::repo::research_outcomes::latest(&state.pool, id).await?;
let runs_in_flight =
cm_db::repo::topology_runs::active_runs_for_research_topic(&state.pool, id).await?;
// D1 fold — surface the wizard-materialized research loop so the
// canvas can swap the classic state-machine buttons for the
// "Managed by scheduled loop" strip.
let managed_by_loop = cm_db::repo::loops::research_loop_for_topic(&state.pool, id)
.await
.unwrap_or(None)
.map(
|(loop_id, title, enabled, next_fire_at, last_run_id, triggers)| ManagedLoop {
loop_id,
title,
enabled,
next_fire_at,
last_run_id,
schedule_summary: summarize_schedule(&triggers),
},
);
Ok(Json(TopicDetail {
topic,
agents,
has_pending_publish_request,
latest_outcome,
runs_in_flight,
managed_by_loop,
}))
}
/// Human-readable one-liner for the loop's triggers jsonb — surfaces
/// on the canvas's "Managed by scheduled loop" strip so users don't
/// have to click through to the loops sidebar to know the cadence.
fn summarize_schedule(triggers: &serde_json::Value) -> String {
let cron = triggers.get("cron").and_then(|v| v.as_str());
let webhook = triggers
.get("webhook_enabled")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let burst = triggers
.get("initial_burst")
.and_then(|v| v.as_u64())
.unwrap_or(0);
let on_artifact = triggers
.get("on_artifact_update")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let mut parts = Vec::new();
if let Some(c) = cron {
parts.push(format!("cron: {c}"));
}
if webhook {
parts.push("webhook".into());
}
if on_artifact {
parts.push("on new artifact".into());
}
if burst > 0 && cron.is_none() && !webhook {
parts.push(if burst == 1 {
"one-shot".into()
} else {
format!("burst of {burst}")
});
}
if parts.is_empty() {
"manual".into()
} else {
parts.join(" · ")
}
}
#[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)
}
/// `DELETE /api/research/:id` — hard-delete a topic and cascade every
/// dependent row. FK cascades on research_topic_agents,
/// research_publish_approvals, and research_outcomes; topology_runs's
/// research_topic_id back-ref is SET NULL so historical runs survive.
/// Returns 204 whether the topic existed or not (idempotent).
pub async fn delete_topic(
State(state): State<AppState>,
Authed(user): Authed,
Path(id): Path<Uuid>,
) -> Result<StatusCode, ApiError> {
cm_db::repo::research_topics::delete(&state.pool, id, user.workspace_id.as_uuid()).await?;
// Teardown the per-topic team container (Path B). Fire-and-forget:
// the topic row is already gone, so any Docker failure is a log-line
// problem, not an API-response problem.
crate::research_container::teardown(id).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` — enqueue the research pipeline and flip
/// status `standby → processing`. Builds a hub_spoke topology graph over the
/// topic's assigned agents (first agent with role_slot="coordinator" becomes
/// the hub; if none is designated the first assigned agent takes that role).
/// The task string is a coordinator prompt derived from the topic's title,
/// description, outcome_kind, and roster. The orchestrator drives the run;
/// `topology_worker::maybe_transition_research_topic` flips the topic to
/// `reviewing` when the last run terminates. Per-agent activity streams via
/// `run_events` and lands on each claw's card through `/api/world/live`.
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)?;
// Allow the fresh-start path (standby) AND the rerun path (topic
// parked in `processing`). For rerun, first cancel any lingering
// queued/running runs (typically orphans left over from a server
// restart mid-pipeline) so the fresh enqueue isn't racing them.
// Terminal states (reviewing / publishing / published) still 409.
let can_start = topic.status == "standby" || topic.status == "processing";
if !can_start {
return Err(ApiError::Conflict);
}
if topic.status == "processing" {
let orphan_ids =
cm_db::repo::topology_runs::active_run_ids_for_research_topic(&state.pool, id).await?;
for run_id in orphan_ids {
// Best-effort — cancel just marks the row; the actual worker
// (if still alive) stops at its next step boundary. If the
// worker is dead (server restart), the row transitions and
// stops being counted as in-flight immediately.
let _ =
cm_db::repo::topology_runs::cancel(&state.pool, run_id, user.workspace_id).await;
}
}
// D1 fold — refuse to double-fire when a scheduled research loop
// already owns this topic. Otherwise clicking the legacy "Start
// research" button while a loop iteration is in flight would spawn
// a competing run through the classic path.
//
// Exception: the rerun path (status = "processing" + orphans just
// cancelled) should be allowed even when a loop owns the topic.
// Otherwise a failed scheduled iteration is un-restartable until
// the loop's next scheduled fire — the user has to wait or delete
// the topic and re-enter the wizard. Rerun preserves the loop
// binding.
let loop_owned = cm_db::repo::loops::research_loop_for_topic(&state.pool, id)
.await
.ok()
.flatten()
.is_some();
if loop_owned && topic.status == "standby" {
return Err(ApiError::Conflict);
}
let slots = cm_db::repo::research_topics::agents(&state.pool, id).await?;
if slots.is_empty() {
return Err(ApiError::BadRequest);
}
// Hydrate names + job titles so the coordinator prompt names each teammate.
let mut roster = Vec::with_capacity(slots.len());
for s in &slots {
let agent =
cm_db::repo::agents::get(&state.pool, cm_domain::AgentId::from(s.agent_id)).await?;
roster.push((s.clone(), agent));
}
// If a repo is bound, clone (shallow) into a per-topic workspace so the
// coordinator prompt can point the team at real files. Best-effort — a
// clone failure logs but still starts the run without repo context.
let repo_context = if let Some(repo_id) = topic.repo_id {
let repo = cm_db::repo::repos::get(&state.pool, repo_id, user.workspace_id).await?;
match ensure_repo_workspace(&state.pool, id, user.workspace_id.as_uuid(), &repo, &topic)
.await
{
Ok(ctx) => Some(ctx),
Err(e) => {
eprintln!(
"research::start_topic({id}): repo clone failed for {}/{}: {e}",
repo.owner, repo.name
);
None
}
}
} else {
None
};
// Spawn the per-topic ZeroClaw team runtime (or reattach if one from a
// prior start already exists). Best-effort in this pass — a failure
// leaves the topic pointing at the workspace-wide gateway (env), which
// still works but skips the isolation. Commit 2 wires the executor to
// prefer the topic's URL when populated.
let state_root = research_workspace_root().join(id.to_string()).join("state");
let repo_path_for_container = repo_context
.as_ref()
.map(|c| std::path::PathBuf::from(&c.path));
if let Some(repo_path) = repo_path_for_container {
match crate::research_container::connect() {
Ok(docker) => {
match crate::research_container::spawn(&docker, id, &repo_path, &state_root).await {
Ok(spawned) => {
if let Err(e) = cm_db::repo::research_topics::set_zeroclaw_container(
&state.pool,
id,
user.workspace_id.as_uuid(),
Some(&spawned.name),
Some(&spawned.gateway_url),
)
.await
{
eprintln!(
"research::start_topic({id}): persist container coords failed: {e}"
);
}
}
Err(e) => {
eprintln!("research::start_topic({id}): spawn team container failed: {e}")
}
}
}
Err(e) => eprintln!(
"research::start_topic({id}): docker connect failed: {e} — skipping team container"
),
}
}
// Coordinator = first slot whose role_slot mentions "coordinator" (case-
// insensitive), else the first slot. The coordinator becomes the hub of
// the hub_spoke graph, so it can talk to every other agent per-turn.
// Parse the topic's chosen topology; default to hub_spoke on unknown
// strings (should be unreachable — create_topic validates the enum).
let topo: cm_topology::TopologyKind =
serde_json::from_value(serde_json::json!(topic.topology_kind.as_str()))
.unwrap_or(cm_topology::TopologyKind::HubSpoke);
let is_pipeline = matches!(topo, cm_topology::TopologyKind::Pipeline);
// hub_spoke / hierarchical / star_moe all put a coordinator at index 0;
// pipeline puts a first-stage worker there (the roster's original order
// *is* the pipeline order). If a slot is explicitly tagged "coordinator"
// and we're not in pipeline mode, promote it to index 0.
if !is_pipeline {
let coord_ix = roster
.iter()
.position(|(s, _)| {
s.role_slot
.as_deref()
.map(|r| r.to_ascii_lowercase().contains("coordinator"))
.unwrap_or(false)
})
.unwrap_or(0);
if coord_ix != 0 {
roster.swap(0, coord_ix);
}
}
let head_label = if is_pipeline {
"stage 1"
} else {
"coordinator"
};
let roles: Vec<String> = roster
.iter()
.enumerate()
.map(|(i, (s, a))| {
if i == 0 {
head_label.to_string()
} else if let Some(r) = &s.role_slot {
r.clone()
} else if !a.job_title.is_empty() {
a.job_title.clone()
} else if is_pipeline {
format!("stage {}", i + 1)
} else {
"specialist".to_string()
}
})
.collect();
let role_refs: Vec<&str> = roles.iter().map(|s| s.as_str()).collect();
let graph = cm_topology::build(topo, &role_refs).map_err(|_| ApiError::BadRequest)?;
let graph_json_str = cm_topology::to_json(&graph).map_err(|_| ApiError::BadRequest)?;
let graph_value: serde_json::Value =
serde_json::from_str(&graph_json_str).map_err(|_| ApiError::BadRequest)?;
// Roster listing, formatted for the prompt.
let roster_lines = roster
.iter()
.enumerate()
.map(|(i, (s, a))| {
let role = if i == 0 && !is_pipeline {
"coordinator (you)".to_string()
} else if i == 0 && is_pipeline {
format!(
"{} (you — stage 1)",
s.role_slot
.as_deref()
.unwrap_or(if !a.job_title.is_empty() {
a.job_title.as_str()
} else {
"opener"
})
)
} else if let Some(r) = &s.role_slot {
if is_pipeline {
format!("{} (stage {})", r, i + 1)
} else {
r.clone()
}
} else if !a.job_title.is_empty() {
a.job_title.clone()
} else {
"specialist".to_string()
};
format!("- {}{}", a.name, role)
})
.collect::<Vec<_>>()
.join("\n");
let mut task = build_coordinator_task(
&topic.title,
&topic.outcome_kind,
&topic.description,
&topo,
&roster_lines,
repo_context.as_ref(),
);
// R2 — reject-with-revision. If the last publish decision was a
// reject with reviewer notes, prepend them to the coordinator task
// as revision guidance. This is what closes the loop: the reviewer's
// critique steers the next iteration through the same run pipeline.
if let Ok(Some(notes)) =
cm_db::repo::research_publish_approvals::latest_rejection_notes(&state.pool, id).await
{
task = format!(
"PRIOR REVIEW NOTES (address these in this revision):\n{notes}\n\n---\n\n{task}"
);
}
let run_id = uuid::Uuid::now_v7();
cm_db::repo::topology_runs::enqueue_run_for_research_topic(
&state.pool,
run_id,
user.workspace_id,
&task,
&graph_value,
id,
)
.await?;
cm_db::repo::research_topics::set_status(
&state.pool,
id,
user.workspace_id.as_uuid(),
"processing",
)
.await?;
Ok(StatusCode::NO_CONTENT)
}
/// `POST /api/research/:id/submit-review` — flips status `processing → reviewing`.
/// Kept as a manual escape hatch: the orchestrator auto-transitions on the
/// last topology_run's completion (see topology_worker's
/// `notify_run_completed` hook), so callers only need this when there are
/// no runs (e.g. a topic parked in `processing` with nothing in flight).
pub async fn submit_review(
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 != "processing" {
return Err(ApiError::Conflict);
}
cm_db::repo::research_topics::set_status(
&state.pool,
id,
user.workspace_id.as_uuid(),
"reviewing",
)
.await?;
Ok(StatusCode::NO_CONTENT)
}
// ── publish approval gate ─────────────────────────────────────────────────
#[derive(serde::Serialize)]
pub struct PublishApprovalCreated {
pub approval_id: Uuid,
}
/// `POST /api/research/:id/request-publish` — a workspace member requests a
/// publish. Topic must be in `reviewing`. Rejects with 409 if there's
/// already a pending request (one at a time). The topic stays in `reviewing`
/// until an approver decides.
pub async fn request_publish(
State(state): State<AppState>,
Authed(user): Authed,
Path(id): Path<Uuid>,
) -> Result<(StatusCode, Json<PublishApprovalCreated>), 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 != "reviewing" {
return Err(ApiError::Conflict);
}
if cm_db::repo::research_publish_approvals::pending_for_topic(&state.pool, id)
.await?
.is_some()
{
return Err(ApiError::Conflict);
}
let approval_id = cm_db::repo::research_publish_approvals::create(
&state.pool,
user.workspace_id.as_uuid(),
id,
user.user_id.as_uuid(),
)
.await?;
Ok((
StatusCode::CREATED,
Json(PublishApprovalCreated { approval_id }),
))
}
pub async fn list_pending_publish(
State(state): State<AppState>,
Authed(user): Authed,
) -> Result<Json<Vec<cm_db::repo::research_publish_approvals::PublishApproval>>, ApiError> {
Ok(Json(
cm_db::repo::research_publish_approvals::list_pending(
&state.pool,
user.workspace_id.as_uuid(),
)
.await?,
))
}
/// Shared body of approve + reject. On approve, transition the topic
/// `reviewing → publishing → published` and teardown its container. On
/// reject: audit the decision. If the reject carries revision `notes`,
/// bump the topic `reviewing → standby` so a subsequent `start_topic`
/// spawns a fresh run with the reviewer's guidance folded into the
/// coordinator prompt (R2). Without notes: legacy behavior — topic
/// stays in reviewing, new publish requests are allowed.
async fn decide_publish(
state: AppState,
user: cm_auth::AuthedUser,
id: Uuid,
approve: bool,
notes: Option<String>,
) -> Result<StatusCode, ApiError> {
// Publish gate is workspace-owner-only. Members can request review (via
// POST /api/research/:id/publish) but cannot decide it — mirrors the
// billing/access-policy scope described in Role::is_owner (spec §1).
if !user.role.is_owner() {
return Err(ApiError::Forbidden);
}
let approval =
cm_db::repo::research_publish_approvals::get(&state.pool, id, user.workspace_id.as_uuid())
.await?
.ok_or(ApiError::NotFound)?;
if approval.status != "pending" {
return Err(ApiError::Conflict);
}
// Pre-flight the no-outcome guard BEFORE we flip the approval row.
// Previous ordering wrote `status = 'approved'` first, then 409'd on
// this check — leaving the DB in a half-flipped state (approval
// approved, topic still in reviewing, no outcome existed) which
// surfaces to reviewers as a permanent "already decided" 409 the
// next click.
if approve {
let outcome =
cm_db::repo::research_outcomes::latest(&state.pool, approval.topic_id).await?;
if outcome.is_none() {
return Err(ApiError::Conflict);
}
}
let notes_ref = notes.as_deref().map(str::trim).filter(|s| !s.is_empty());
let landed = cm_db::repo::research_publish_approvals::decide(
&state.pool,
id,
user.workspace_id.as_uuid(),
user.user_id.as_uuid(),
approve,
notes_ref,
)
.await?;
// Someone else won the race — treat as a no-op success; the topic
// transition already happened (or didn't) with their decision.
if !landed {
return Ok(StatusCode::NO_CONTENT);
}
if approve {
// reviewing → publishing → published in one API call.
//
// Real async packaging isn't a thing yet — the artifact is the
// markdown body already stored in research_outcomes when the
// final run completed (see topology_worker). Two transitions:
//
// 1. set_status('publishing') stamps published_at on the first
// landing (schema-level trigger — see set_status docs).
// 2. set_status('published') is the terminal state that the
// sidebar bucket count reads. Nothing else fires; if we later
// add real packaging (pdf render, mirror to a store) we can
// make step 2 an async job driven off the 'publishing' row.
cm_db::repo::research_topics::set_status(
&state.pool,
approval.topic_id,
user.workspace_id.as_uuid(),
"publishing",
)
.await?;
cm_db::repo::research_topics::set_status(
&state.pool,
approval.topic_id,
user.workspace_id.as_uuid(),
"published",
)
.await?;
// The research work is done — tear down the per-topic team
// container. Artifact-writing already happened during runs.
crate::research_container::teardown(approval.topic_id).await;
} else if notes_ref.is_some() {
// Reject-with-revision (R2): flip the topic back to standby so
// the reviewer's guidance takes effect on the next `start_topic`
// via `latest_rejection_notes` in the coordinator prompt.
cm_db::repo::research_topics::set_status(
&state.pool,
approval.topic_id,
user.workspace_id.as_uuid(),
"standby",
)
.await?;
}
Ok(StatusCode::NO_CONTENT)
}
#[derive(serde::Deserialize, Default)]
pub struct RejectPublishRequest {
/// Optional revision guidance. When present + non-empty, decide_publish
/// bumps the topic back to standby and start_topic reads the notes
/// from the approval row to steer the next coordinator prompt.
#[serde(default)]
pub notes: Option<String>,
}
pub async fn approve_publish(
State(state): State<AppState>,
Authed(user): Authed,
Path(id): Path<Uuid>,
) -> Result<StatusCode, ApiError> {
decide_publish(state, user, id, true, None).await
}
pub async fn reject_publish(
State(state): State<AppState>,
Authed(user): Authed,
Path(id): Path<Uuid>,
body: Option<Json<RejectPublishRequest>>,
) -> Result<StatusCode, ApiError> {
let notes = body.and_then(|Json(b)| b.notes);
decide_publish(state, user, id, false, notes).await
}
/// `GET /api/research/:id/artifact` — download the latest outcome as
/// markdown (Content-Disposition: attachment). Any topic that has a
/// stored outcome can serve one — we don't gate on status='published'
/// because reviewers may want to inspect the draft before approving.
/// Workspace-scoped ownership check enforced via research_topics::get.
pub async fn get_artifact(
State(state): State<AppState>,
Authed(user): Authed,
Path(id): Path<Uuid>,
) -> Result<impl IntoResponse, ApiError> {
let topic = cm_db::repo::research_topics::get(&state.pool, id, user.workspace_id.as_uuid())
.await?
.ok_or(ApiError::NotFound)?;
let outcome = cm_db::repo::research_outcomes::latest(&state.pool, id)
.await?
.ok_or(ApiError::NotFound)?;
let safe_title: String = topic
.title
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
c
} else {
'-'
}
})
.collect();
let filename = format!("{}-v{}.md", safe_title.trim_matches('-'), outcome.version);
Ok((
[
(
header::CONTENT_TYPE,
"text/markdown; charset=utf-8".to_string(),
),
(
header::CONTENT_DISPOSITION,
format!("attachment; filename=\"{filename}\""),
),
],
outcome.body_md,
))
}
// Pipeline diagnostics moved to `research_pipeline.rs` to keep this file
// under the 1250-line budget. Route registration in lib.rs points at
// `routes::research_pipeline::pipeline_state`.
// ── 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,
}))
}