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.
1276 lines
49 KiB
Rust
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,
|
|
}))
|
|
}
|