//! 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 · \n\ ## Executive summary\n\ · Focus areas: \n\ · Papers surveyed: \n\ · Recommended integrations: (P0: , P1: , P2: )\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- · · P0|P1|P2 · \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, #[serde(default)] pub agents: Vec, /// 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, /// 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, /// 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, } // 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, #[serde(default)] pub provider: Option, #[serde(default)] pub owner: Option, #[serde(default)] pub name: Option, #[serde(default)] pub default_branch: Option, } /// 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, } #[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, Authed(user): Authed, Json(body): Json, ) -> Result<(StatusCode, Json), 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, Authed(user): Authed, ) -> Result>, ApiError> { let rows = cm_db::repo::research_topics::list(&state.pool, user.workspace_id.as_uuid()).await?; let ids: Vec = 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 = 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, /// 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, /// 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, } #[derive(Serialize)] pub struct ManagedLoop { pub loop_id: Uuid, pub title: String, pub enabled: bool, pub next_fire_at: Option, pub last_run_id: Option, /// 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, Authed(user): Authed, Path(id): Path, ) -> Result, 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, Authed(user): Authed, Path(id): Path, Json(body): Json, ) -> Result { 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, Authed(user): Authed, Path(id): Path, Json(body): Json, ) -> Result { 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, Authed(user): Authed, Path(id): Path, ) -> Result { 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, Authed(user): Authed, Path((id, agent_id)): Path<(Uuid, Uuid)>, ) -> Result { 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, Authed(user): Authed, Path(id): Path, ) -> Result { 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 = 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::>() .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, Authed(user): Authed, Path(id): Path, ) -> Result { 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, Authed(user): Authed, Path(id): Path, ) -> Result<(StatusCode, Json), 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, Authed(user): Authed, ) -> Result>, 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, ) -> Result { // 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, } pub async fn approve_publish( State(state): State, Authed(user): Authed, Path(id): Path, ) -> Result { decide_publish(state, user, id, true, None).await } pub async fn reject_publish( State(state): State, Authed(user): Authed, Path(id): Path, body: Option>, ) -> Result { 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, Authed(user): Authed, Path(id): Path, ) -> Result { 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, 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, Authed(_user): Authed, Json(body): Json, ) -> Result, 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, })) }