research pipeline v2: topology-aware start + persisted draft
ci / gates (push) Successful in 7s
ci / frontend (push) Successful in 37s
ci / rust (push) Successful in 2m42s
ci / e2e (push) Has been skipped
ci / publish (push) Successful in 4m2s

Three connected changes that turn "Start research" from a status
flip into a real pipeline that produces a reviewable artifact:

- Migration 0036: adds research_topics.topology_kind (default
  'hub_spoke') and a new research_outcomes table
  (id, topic_id, version DESC, body_md, produced_by_run_id, created_at)
  so each run's final synthesis is versioned and persistent.

- Wizard now has a topology picker in the Outcome step —
  hub_spoke / pipeline / hierarchical / star_moe — with copy that
  steers users to the right shape (Pipeline for research → distill
  → analyze → implement rosters, hub_spoke for the coordinator-
  and-specialists default).

- start_topic reads the chosen topology_kind, parses it into a
  cm_topology::TopologyKind, and dispatches a per-shape coordinator
  prompt via build_coordinator_task. Pipeline explicitly tells
  stage 1 not to write the final artifact and propagates a
  "final stage MUST emit a complete markdown document with
  measurable acceptance criteria" instruction downstream. The
  graph builder is called with the topology the user actually
  picked instead of hard-coded HubSpoke.

- topology_worker::freeze_research_outcome fires after every
  successful complete(). It looks up research_topic_id on the run;
  if set and final_output is non-empty, it inserts a new
  research_outcomes row (version auto-derived server-side via
  coalesce(max(version), 0) + 1). Best-effort — a DB hiccup logs
  but doesn't fail the run.

- TopicDetail now includes topology_kind and latest_outcome.
  ResearchCanvas swaps in the outcome's body_md (rendered as
  pre-wrap markdown, versioned header, produced-at timestamp)
  whenever an outcome exists; the original prompt collapses into
  an "Original prompt" <details> below so it's still one click
  away. Pre-run topics still show the description as before.

Follow-ups still open: reject-with-revision loop feeding the
coordinator, publishing → published transition + real artifact
export (md / pdf), and an approvals inbox surface for reviewers.
This commit is contained in:
Omar Sobh
2026-07-08 17:13:46 -07:00
parent 7e2b02d8bb
commit a2d3d85ebe
17 changed files with 656 additions and 94 deletions
@@ -1,6 +1,6 @@
{ {
"db_name": "PostgreSQL", "db_name": "PostgreSQL",
"query": "SELECT id, workspace_id, title, description, outcome_kind, status,\n created_by, created_at, updated_at, published_at\n FROM research_topics\n WHERE workspace_id = $1\n ORDER BY updated_at DESC", "query": "SELECT id, workspace_id, title, description, outcome_kind, status,\n created_by, created_at, updated_at, published_at, topology_kind\n FROM research_topics\n WHERE workspace_id = $1\n ORDER BY updated_at DESC",
"describe": { "describe": {
"columns": [ "columns": [
{ {
@@ -52,6 +52,11 @@
"ordinal": 9, "ordinal": 9,
"name": "published_at", "name": "published_at",
"type_info": "Timestamptz" "type_info": "Timestamptz"
},
{
"ordinal": 10,
"name": "topology_kind",
"type_info": "Text"
} }
], ],
"parameters": { "parameters": {
@@ -69,8 +74,9 @@
false, false,
false, false,
false, false,
true true,
false
] ]
}, },
"hash": "080aa9dc0fe2e51c239e721dab1c33cd6ad31e679484e277a954c0781fdaa342" "hash": "7f85b5b9fa303e3f232359832a6630f3ac999a992f150d6cede6362d51eac063"
} }
@@ -1,6 +1,6 @@
{ {
"db_name": "PostgreSQL", "db_name": "PostgreSQL",
"query": "INSERT INTO research_topics\n (id, workspace_id, title, description, outcome_kind, status, created_by)\n VALUES ($1, $2, $3, $4, $5, 'standby', $6)", "query": "INSERT INTO research_topics\n (id, workspace_id, title, description, outcome_kind, topology_kind, status, created_by)\n VALUES ($1, $2, $3, $4, $5, $6, 'standby', $7)",
"describe": { "describe": {
"columns": [], "columns": [],
"parameters": { "parameters": {
@@ -10,10 +10,11 @@
"Text", "Text",
"Text", "Text",
"Text", "Text",
"Text",
"Uuid" "Uuid"
] ]
}, },
"nullable": [] "nullable": []
}, },
"hash": "040eedfd8cf97e0dbb5719fd150f961b938f3792d764d3e4f724c6da48294acd" "hash": "819ff5b7f2225a932e2181eb3bd3de45b98cca6d3e88dd9f686a9eda669803b1"
} }
@@ -0,0 +1,55 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO research_outcomes (id, topic_id, version, body_md, produced_by_run_id)\n SELECT $1, $2, coalesce(max(version), 0) + 1, $3, $4\n FROM research_outcomes\n WHERE topic_id = $2\n RETURNING id, topic_id, version, body_md, produced_by_run_id, created_at",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "topic_id",
"type_info": "Uuid"
},
{
"ordinal": 2,
"name": "version",
"type_info": "Int4"
},
{
"ordinal": 3,
"name": "body_md",
"type_info": "Text"
},
{
"ordinal": 4,
"name": "produced_by_run_id",
"type_info": "Uuid"
},
{
"ordinal": 5,
"name": "created_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Uuid",
"Uuid",
"Text",
"Uuid"
]
},
"nullable": [
false,
false,
false,
false,
true,
false
]
},
"hash": "b95381b1c598da85edc3577318664576e40003d4113161b2012ea7863f780042"
}
@@ -1,6 +1,6 @@
{ {
"db_name": "PostgreSQL", "db_name": "PostgreSQL",
"query": "SELECT id, workspace_id, title, description, outcome_kind, status,\n created_by, created_at, updated_at, published_at\n FROM research_topics\n WHERE id = $1 AND workspace_id = $2", "query": "SELECT id, workspace_id, title, description, outcome_kind, status,\n created_by, created_at, updated_at, published_at, topology_kind\n FROM research_topics\n WHERE id = $1 AND workspace_id = $2",
"describe": { "describe": {
"columns": [ "columns": [
{ {
@@ -52,6 +52,11 @@
"ordinal": 9, "ordinal": 9,
"name": "published_at", "name": "published_at",
"type_info": "Timestamptz" "type_info": "Timestamptz"
},
{
"ordinal": 10,
"name": "topology_kind",
"type_info": "Text"
} }
], ],
"parameters": { "parameters": {
@@ -70,8 +75,9 @@
false, false,
false, false,
false, false,
true true,
false
] ]
}, },
"hash": "eecbf1bc6a6ed59635ea26dffbba8ed014e58a6e0da19576b01912936317ed1a" "hash": "cb9c83b58db07f741510ec96e9301c5fa9c4e22f102787cbc99e488d417acf1a"
} }
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT research_topic_id FROM topology_runs WHERE id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "research_topic_id",
"type_info": "Uuid"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
true
]
},
"hash": "dc9b55c84dad42d5b397353d3b5d5ef5af28cc8c51802526c70b8b2f6e4c0c64"
}
@@ -0,0 +1,52 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id, topic_id, version, body_md, produced_by_run_id, created_at\n FROM research_outcomes\n WHERE topic_id = $1\n ORDER BY version DESC\n LIMIT 1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "topic_id",
"type_info": "Uuid"
},
{
"ordinal": 2,
"name": "version",
"type_info": "Int4"
},
{
"ordinal": 3,
"name": "body_md",
"type_info": "Text"
},
{
"ordinal": 4,
"name": "produced_by_run_id",
"type_info": "Uuid"
},
{
"ordinal": 5,
"name": "created_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
false,
false,
false,
false,
true,
false
]
},
"hash": "fa7257ae21b4faff3e8509c9813d7e1d326660c142bf42f4bb065d07b1982318"
}
+160 -33
View File
@@ -42,10 +42,101 @@ pub struct CreateTopicRequest {
pub title: String, pub title: String,
pub description: String, pub description: String,
pub outcome_kind: String, pub outcome_kind: String,
/// hub_spoke | pipeline | hierarchical | star_moe. Defaults to hub_spoke.
#[serde(default)]
pub topology_kind: Option<String>,
#[serde(default)] #[serde(default)]
pub agents: Vec<AgentSlotInput>, pub agents: Vec<AgentSlotInput>,
} }
/// 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,
) -> String {
use cm_topology::TopologyKind::*;
let framing = format!(
"RESEARCH TOPIC: {title}\n\
OUTCOME KIND: {outcome} (spec / prod_plan / roadmap / paper)\n\n\
DESCRIPTION:\n{description}\n\n\
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}")
}
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct AgentSlotInput { pub struct AgentSlotInput {
pub agent_id: Uuid, pub agent_id: Uuid,
@@ -70,6 +161,10 @@ pub async fn create_topic(
return Err(ApiError::BadRequest); return Err(ApiError::BadRequest);
} }
check_outcome(&body.outcome_kind)?; 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);
}
// Ownership check before any writes: every agent must be in the caller's // Ownership check before any writes: every agent must be in the caller's
// workspace. Refuses to leak "agent exists" if it isn't visible. // workspace. Refuses to leak "agent exists" if it isn't visible.
@@ -86,6 +181,7 @@ pub async fn create_topic(
body.title.trim(), body.title.trim(),
body.description.trim(), body.description.trim(),
&body.outcome_kind, &body.outcome_kind,
topology_kind,
user.user_id.as_uuid(), user.user_id.as_uuid(),
) )
.await?; .await?;
@@ -143,6 +239,12 @@ pub struct TopicDetail {
/// "Awaiting reviewer approval" instead — avoids the 409 the user /// "Awaiting reviewer approval" instead — avoids the 409 the user
/// gets from double-clicking. /// gets from double-clicking.
pub has_pending_publish_request: bool, 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>,
} }
pub async fn get_topic( pub async fn get_topic(
@@ -158,10 +260,12 @@ pub async fn get_topic(
cm_db::repo::research_publish_approvals::pending_for_topic(&state.pool, id) cm_db::repo::research_publish_approvals::pending_for_topic(&state.pool, id)
.await? .await?
.is_some(); .is_some();
let latest_outcome = cm_db::repo::research_outcomes::latest(&state.pool, id).await?;
Ok(Json(TopicDetail { Ok(Json(TopicDetail {
topic, topic,
agents, agents,
has_pending_publish_request, has_pending_publish_request,
latest_outcome,
})) }))
} }
@@ -266,51 +370,82 @@ pub async fn start_topic(
// Coordinator = first slot whose role_slot mentions "coordinator" (case- // Coordinator = first slot whose role_slot mentions "coordinator" (case-
// insensitive), else the first slot. The coordinator becomes the hub of // 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. // the hub_spoke graph, so it can talk to every other agent per-turn.
let coord_ix = roster // Parse the topic's chosen topology; default to hub_spoke on unknown
.iter() // strings (should be unreachable — create_topic validates the enum).
.position(|(s, _)| { let topo: cm_topology::TopologyKind =
s.role_slot serde_json::from_value(serde_json::json!(topic.topology_kind.as_str()))
.as_deref() .unwrap_or(cm_topology::TopologyKind::HubSpoke);
.map(|r| r.to_ascii_lowercase().contains("coordinator")) let is_pipeline = matches!(topo, cm_topology::TopologyKind::Pipeline);
.unwrap_or(false) // hub_spoke / hierarchical / star_moe all put a coordinator at index 0;
}) // pipeline puts a first-stage worker there (the roster's original order
.unwrap_or(0); // *is* the pipeline order). If a slot is explicitly tagged "coordinator"
if coord_ix != 0 { // and we're not in pipeline mode, promote it to index 0.
roster.swap(0, coord_ix); 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);
}
} }
// Build a role list where element 0 is the coordinator (hub) and the rest let head_label = if is_pipeline {
// are spokes. cm_topology's hub_spoke builder wires edges hub↔every spoke. "stage 1"
} else {
"coordinator"
};
let roles: Vec<String> = roster let roles: Vec<String> = roster
.iter() .iter()
.enumerate() .enumerate()
.map(|(i, (s, a))| { .map(|(i, (s, a))| {
if i == 0 { if i == 0 {
"coordinator".to_string() head_label.to_string()
} else if let Some(r) = &s.role_slot { } else if let Some(r) = &s.role_slot {
r.clone() r.clone()
} else if !a.job_title.is_empty() { } else if !a.job_title.is_empty() {
a.job_title.clone() a.job_title.clone()
} else if is_pipeline {
format!("stage {}", i + 1)
} else { } else {
"specialist".to_string() "specialist".to_string()
} }
}) })
.collect(); .collect();
let role_refs: Vec<&str> = roles.iter().map(|s| s.as_str()).collect(); let role_refs: Vec<&str> = roles.iter().map(|s| s.as_str()).collect();
let graph = cm_topology::build(cm_topology::TopologyKind::HubSpoke, &role_refs) let graph = cm_topology::build(topo, &role_refs).map_err(|_| ApiError::BadRequest)?;
.map_err(|_| ApiError::BadRequest)?;
let graph_json_str = cm_topology::to_json(&graph).map_err(|_| ApiError::BadRequest)?; let graph_json_str = cm_topology::to_json(&graph).map_err(|_| ApiError::BadRequest)?;
let graph_value: serde_json::Value = let graph_value: serde_json::Value =
serde_json::from_str(&graph_json_str).map_err(|_| ApiError::BadRequest)?; serde_json::from_str(&graph_json_str).map_err(|_| ApiError::BadRequest)?;
// Coordinator prompt: topic framing + roster + delegation instruction. // Roster listing, formatted for the prompt.
let roster_lines = roster let roster_lines = roster
.iter() .iter()
.enumerate() .enumerate()
.map(|(i, (s, a))| { .map(|(i, (s, a))| {
let role = if i == 0 { let role = if i == 0 && !is_pipeline {
"coordinator (you)".to_string() "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 { } else if let Some(r) = &s.role_slot {
r.clone() if is_pipeline {
format!("{} (stage {})", r, i + 1)
} else {
r.clone()
}
} else if !a.job_title.is_empty() { } else if !a.job_title.is_empty() {
a.job_title.clone() a.job_title.clone()
} else { } else {
@@ -320,20 +455,12 @@ pub async fn start_topic(
}) })
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join("\n"); .join("\n");
let task = format!( let task = build_coordinator_task(
"RESEARCH TOPIC: {title}\n\ &topic.title,
OUTCOME KIND: {outcome} (spec / prod_plan / roadmap / paper)\n\n\ &topic.outcome_kind,
DESCRIPTION:\n{description}\n\n\ &topic.description,
TEAM (hub_spoke — you are the hub, the rest are spokes you can address per-turn):\n{roster}\n\n\ &topo,
YOUR JOB (coordinator):\n\ &roster_lines,
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 {outcome} that satisfies the description.\n\
4. Cite each spoke's contribution where it lands in the final artifact.",
title = topic.title,
outcome = topic.outcome_kind,
description = topic.description,
roster = roster_lines,
); );
let run_id = uuid::Uuid::now_v7(); let run_id = uuid::Uuid::now_v7();
+25
View File
@@ -127,6 +127,7 @@ async fn run_job(
if let Err(e) = cm_db::repo::topology_runs::complete(pool, id, &value).await { if let Err(e) = cm_db::repo::topology_runs::complete(pool, id, &value).await {
eprintln!("topology_worker: complete({id}) failed: {e}"); eprintln!("topology_worker: complete({id}) failed: {e}");
} }
freeze_research_outcome(pool, id, &record.final_output).await;
} }
Err(e) => { Err(e) => {
// Don't clobber a cancellation (or any already-terminal state) with `failed`. // Don't clobber a cancellation (or any already-terminal state) with `failed`.
@@ -142,6 +143,30 @@ async fn run_job(
maybe_transition_research_topic(pool, id).await; maybe_transition_research_topic(pool, id).await;
} }
/// If this run belongs to a research topic, snapshot the orchestrator's
/// final synthesis as a versioned `research_outcomes` row. The frontend
/// canvas reads `latest_outcome` for anything past `standby` so reviewers
/// see the produced draft rather than the original prompt. Best-effort:
/// a failure here logs but doesn't fail the run.
async fn freeze_research_outcome(pool: &PgPool, run_id: Uuid, final_output: &str) {
let topic_id = match cm_db::repo::topology_runs::research_topic_id(pool, run_id).await {
Ok(Some(id)) => id,
Ok(None) => return,
Err(e) => {
eprintln!("topology_worker: research_topic_id({run_id}) failed: {e}");
return;
}
};
if final_output.trim().is_empty() {
return;
}
if let Err(e) =
cm_db::repo::research_outcomes::insert(pool, topic_id, final_output, Some(run_id)).await
{
eprintln!("topology_worker: research_outcomes::insert({run_id}) failed: {e}");
}
}
/// Post-terminal hook: if this run belongs to a research topic and it was /// Post-terminal hook: if this run belongs to a research topic and it was
/// the last sibling in flight, transition the topic `processing → reviewing`. /// the last sibling in flight, transition the topic `processing → reviewing`.
/// Best-effort — a DB hiccup here logs but doesn't fail the run. /// Best-effort — a DB hiccup here logs but doesn't fail the run.
+2
View File
@@ -279,6 +279,7 @@ async fn notify_run_completed_transitions_topic_when_no_siblings_in_flight() {
"Topic", "Topic",
"desc", "desc",
"spec", "spec",
"hub_spoke",
user_id.as_uuid(), user_id.as_uuid(),
) )
.await .await
@@ -328,6 +329,7 @@ async fn notify_run_completed_leaves_topic_processing_when_siblings_in_flight()
"Topic", "Topic",
"desc", "desc",
"spec", "spec",
"hub_spoke",
user_id.as_uuid(), user_id.as_uuid(),
) )
.await .await
+1
View File
@@ -18,6 +18,7 @@ pub mod orgs;
pub mod outbox; pub mod outbox;
pub mod repo_connections; pub mod repo_connections;
pub mod repos; pub mod repos;
pub mod research_outcomes;
pub mod research_publish_approvals; pub mod research_publish_approvals;
pub mod research_topics; pub mod research_topics;
pub mod routine_runs; pub mod routine_runs;
@@ -0,0 +1,81 @@
//! Persisted research artifacts — one row per run's final synthesis. When
//! `topology_worker` completes a run tagged with a `research_topic_id`, it
//! extracts the orchestrator's `RunRecord.final_output` and calls
//! [`insert`] here. The frontend canvas then renders the latest outcome
//! instead of the topic description when the topic has moved past
//! `standby`, so reviewers see the actual draft.
//!
//! Version is per-topic and monotonically increasing so reject-with-
//! revision loops accumulate history rather than clobber prior drafts.
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use time::OffsetDateTime;
use uuid::Uuid;
use crate::DbError;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Outcome {
pub id: Uuid,
pub topic_id: Uuid,
pub version: i32,
pub body_md: String,
pub produced_by_run_id: Option<Uuid>,
pub created_at: OffsetDateTime,
}
/// Insert a new outcome. Version is derived server-side as `max(version) + 1`
/// for the topic (starting at 1) so callers never need to know the current
/// count. Returns the persisted row.
pub async fn insert(
pool: &PgPool,
topic_id: Uuid,
body_md: &str,
produced_by_run_id: Option<Uuid>,
) -> Result<Outcome, DbError> {
let id = Uuid::now_v7();
let row = sqlx::query!(
"INSERT INTO research_outcomes (id, topic_id, version, body_md, produced_by_run_id)
SELECT $1, $2, coalesce(max(version), 0) + 1, $3, $4
FROM research_outcomes
WHERE topic_id = $2
RETURNING id, topic_id, version, body_md, produced_by_run_id, created_at",
id,
topic_id,
body_md,
produced_by_run_id,
)
.fetch_one(pool)
.await?;
Ok(Outcome {
id: row.id,
topic_id: row.topic_id,
version: row.version,
body_md: row.body_md,
produced_by_run_id: row.produced_by_run_id,
created_at: row.created_at,
})
}
/// Newest outcome for a topic, or `None` if no run has completed yet.
pub async fn latest(pool: &PgPool, topic_id: Uuid) -> Result<Option<Outcome>, DbError> {
let row = sqlx::query!(
"SELECT id, topic_id, version, body_md, produced_by_run_id, created_at
FROM research_outcomes
WHERE topic_id = $1
ORDER BY version DESC
LIMIT 1",
topic_id,
)
.fetch_optional(pool)
.await?;
Ok(row.map(|r| Outcome {
id: r.id,
topic_id: r.topic_id,
version: r.version,
body_md: r.body_md,
produced_by_run_id: r.produced_by_run_id,
created_at: r.created_at,
}))
}
+9 -4
View File
@@ -24,6 +24,9 @@ pub struct ResearchTopic {
pub created_at: OffsetDateTime, pub created_at: OffsetDateTime,
pub updated_at: OffsetDateTime, pub updated_at: OffsetDateTime,
pub published_at: Option<OffsetDateTime>, pub published_at: Option<OffsetDateTime>,
/// Topology shape start_topic builds when firing this topic. Options:
/// hub_spoke, pipeline, hierarchical, star_moe. Defaults to hub_spoke.
pub topology_kind: String,
} }
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
@@ -39,18 +42,20 @@ pub async fn create(
title: &str, title: &str,
description: &str, description: &str,
outcome_kind: &str, outcome_kind: &str,
topology_kind: &str,
created_by: Uuid, created_by: Uuid,
) -> Result<Uuid, DbError> { ) -> Result<Uuid, DbError> {
let id = Uuid::now_v7(); let id = Uuid::now_v7();
sqlx::query!( sqlx::query!(
"INSERT INTO research_topics "INSERT INTO research_topics
(id, workspace_id, title, description, outcome_kind, status, created_by) (id, workspace_id, title, description, outcome_kind, topology_kind, status, created_by)
VALUES ($1, $2, $3, $4, $5, 'standby', $6)", VALUES ($1, $2, $3, $4, $5, $6, 'standby', $7)",
id, id,
workspace_id, workspace_id,
title, title,
description, description,
outcome_kind, outcome_kind,
topology_kind,
created_by, created_by,
) )
.execute(pool) .execute(pool)
@@ -63,7 +68,7 @@ pub async fn list(pool: &PgPool, workspace_id: Uuid) -> Result<Vec<ResearchTopic
let rows = sqlx::query_as!( let rows = sqlx::query_as!(
ResearchTopic, ResearchTopic,
"SELECT id, workspace_id, title, description, outcome_kind, status, "SELECT id, workspace_id, title, description, outcome_kind, status,
created_by, created_at, updated_at, published_at created_by, created_at, updated_at, published_at, topology_kind
FROM research_topics FROM research_topics
WHERE workspace_id = $1 WHERE workspace_id = $1
ORDER BY updated_at DESC", ORDER BY updated_at DESC",
@@ -82,7 +87,7 @@ pub async fn get(
let row = sqlx::query_as!( let row = sqlx::query_as!(
ResearchTopic, ResearchTopic,
"SELECT id, workspace_id, title, description, outcome_kind, status, "SELECT id, workspace_id, title, description, outcome_kind, status,
created_by, created_at, updated_at, published_at created_by, created_at, updated_at, published_at, topology_kind
FROM research_topics FROM research_topics
WHERE id = $1 AND workspace_id = $2", WHERE id = $1 AND workspace_id = $2",
id, id,
+13
View File
@@ -147,6 +147,19 @@ pub async fn enqueue_run_for_team(
Ok(()) Ok(())
} }
/// The research topic this run belongs to, if any. Used by the topology
/// worker's `freeze_research_outcome` post-hook to snapshot the run's
/// final synthesis into `research_outcomes`.
pub async fn research_topic_id(pool: &PgPool, id: Uuid) -> Result<Option<Uuid>, DbError> {
let row = sqlx::query!(
"SELECT research_topic_id FROM topology_runs WHERE id = $1",
id,
)
.fetch_optional(pool)
.await?;
Ok(row.and_then(|r| r.research_topic_id))
}
/// Enqueue a durable run bound to a research topic. `research_topic_id` is /// Enqueue a durable run bound to a research topic. `research_topic_id` is
/// stored so `notify_run_completed` can flip the owning topic /// stored so `notify_run_completed` can flip the owning topic
/// `processing → reviewing` when its last run terminates (see /// `processing → reviewing` when its last run terminates (see
@@ -237,27 +237,82 @@ export function ResearchCanvas({
)} )}
</div> </div>
{/* Description */} {/* Draft (post-run) or Description (pre-run). Once the pipeline has
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}> produced an outcome, reviewers should see that instead of the
<div style={sectionHeader}>Description</div> prompt they started from. The prompt is still shown as a
<pre collapsed reference below. */}
style={{ {topic.latest_outcome ? (
padding: 16, <>
borderRadius: 10, <div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
background: "#101014", <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
border: "1px solid rgba(255,255,255,.06)", <div style={sectionHeader}>Draft · v{topic.latest_outcome.version}</div>
color: "#eaeaee", <div style={{ fontFamily: mono, fontSize: 10, color: "#6a6a72" }}>
fontFamily: mono, produced {new Date(topic.latest_outcome.created_at).toLocaleString()}
fontSize: 12.5, </div>
lineHeight: 1.6, </div>
whiteSpace: "pre-wrap", <pre
wordBreak: "break-word", style={{
margin: 0, padding: 16,
}} borderRadius: 10,
> background: "#0d0d10",
{topic.description} border: "1px solid rgba(201,138,240,.2)",
</pre> color: "#eaeaee",
</div> fontFamily: mono,
fontSize: 12.5,
lineHeight: 1.6,
whiteSpace: "pre-wrap",
wordBreak: "break-word",
margin: 0,
maxHeight: "62vh",
overflow: "auto",
}}
>
{topic.latest_outcome.body_md}
</pre>
</div>
<details>
<summary style={{ ...sectionHeader, cursor: "pointer" }}>Original prompt</summary>
<pre
style={{
padding: 14,
marginTop: 8,
borderRadius: 10,
background: "#0a0a0c",
border: "1px solid rgba(255,255,255,.05)",
color: "#8a8a92",
fontFamily: mono,
fontSize: 11.5,
lineHeight: 1.55,
whiteSpace: "pre-wrap",
wordBreak: "break-word",
}}
>
{topic.description}
</pre>
</details>
</>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<div style={sectionHeader}>Description</div>
<pre
style={{
padding: 16,
borderRadius: 10,
background: "#101014",
border: "1px solid rgba(255,255,255,.06)",
color: "#eaeaee",
fontFamily: mono,
fontSize: 12.5,
lineHeight: 1.6,
whiteSpace: "pre-wrap",
wordBreak: "break-word",
margin: 0,
}}
>
{topic.description}
</pre>
</div>
)}
{/* Stage explainer + primary action */} {/* Stage explainer + primary action */}
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}> <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
@@ -14,6 +14,7 @@ import {
createTopic, createTopic,
wizardRefine, wizardRefine,
type OutcomeKind, type OutcomeKind,
type TopologyKind,
} from "@/lib/api/research"; } from "@/lib/api/research";
import { RepoPicker, type PickedRepo } from "./RepoPicker"; import { RepoPicker, type PickedRepo } from "./RepoPicker";
import { NoAgentsGate } from "./NoAgentsGate"; import { NoAgentsGate } from "./NoAgentsGate";
@@ -21,6 +22,29 @@ import { NoAgentsGate } from "./NoAgentsGate";
const mono = const mono =
"ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace"; "ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace";
const TOPOLOGIES: { kind: TopologyKind; label: string; hint: string }[] = [
{
kind: "hub_spoke",
label: "Hub · spoke",
hint: "A coordinator delegates to specialists and synthesizes their outputs. Good default.",
},
{
kind: "pipeline",
label: "Pipeline",
hint: "Linear stages: each teammate hands off to the next. Last stage owns the final artifact. Ideal for research → distill → analyze → implement chains.",
},
{
kind: "hierarchical",
label: "Hierarchical",
hint: "Root plans, children work in parallel, root synthesizes. Best when sub-problems are independent.",
},
{
kind: "star_moe",
label: "Star · MoE",
hint: "Router classifies subtasks and dispatches to the domain expert best-fit for each. Mixed-domain topics.",
},
];
const OUTCOMES: { kind: OutcomeKind; label: string; hint: string }[] = [ const OUTCOMES: { kind: OutcomeKind; label: string; hint: string }[] = [
{ kind: "spec", label: "Spec", hint: "A structured technical specification." }, { kind: "spec", label: "Spec", hint: "A structured technical specification." },
{ {
@@ -54,6 +78,7 @@ export function ResearchWizard({
const [prompt, setPrompt] = useState(""); const [prompt, setPrompt] = useState("");
const [repo, setRepo] = useState<PickedRepo | null>(null); const [repo, setRepo] = useState<PickedRepo | null>(null);
const [outcome, setOutcome] = useState<OutcomeKind>("spec"); const [outcome, setOutcome] = useState<OutcomeKind>("spec");
const [topology, setTopology] = useState<TopologyKind>("hub_spoke");
const [refining, setRefining] = useState(false); const [refining, setRefining] = useState(false);
const [refineError, setRefineError] = useState<string | null>(null); const [refineError, setRefineError] = useState<string | null>(null);
const [title, setTitle] = useState(""); const [title, setTitle] = useState("");
@@ -89,6 +114,7 @@ export function ResearchWizard({
title: title.trim(), title: title.trim(),
description: description.trim(), description: description.trim(),
outcome_kind: outcome, outcome_kind: outcome,
topology_kind: topology,
agents: selected.map((s) => ({ agents: selected.map((s) => ({
agent_id: s.agent_id, agent_id: s.agent_id,
role_slot: s.role_slot.trim() || undefined, role_slot: s.role_slot.trim() || undefined,
@@ -268,34 +294,73 @@ export function ResearchWizard({
)} )}
{agents.length > 0 && step === 4 && ( {agents.length > 0 && step === 4 && (
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}> <div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
<span style={labelStyle}>Outcome kind</span> <div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
{OUTCOMES.map((o) => ( <span style={labelStyle}>Outcome kind</span>
<label {OUTCOMES.map((o) => (
key={o.kind} <label
style={{ key={o.kind}
display: "flex", style={{
gap: 10, display: "flex",
padding: "10px 12px", gap: 10,
borderRadius: 10, padding: "10px 12px",
border: `1px solid ${outcome === o.kind ? "rgba(255,111,97,.5)" : "rgba(255,255,255,.1)"}`, borderRadius: 10,
background: outcome === o.kind ? "rgba(255,111,97,.08)" : "transparent", border: `1px solid ${outcome === o.kind ? "rgba(255,111,97,.5)" : "rgba(255,255,255,.1)"}`,
cursor: "pointer", background: outcome === o.kind ? "rgba(255,111,97,.08)" : "transparent",
}} cursor: "pointer",
> }}
<input >
type="radio" <input
name="outcome" type="radio"
checked={outcome === o.kind} name="outcome"
onChange={() => setOutcome(o.kind)} checked={outcome === o.kind}
style={{ marginTop: 2 }} onChange={() => setOutcome(o.kind)}
/> style={{ marginTop: 2 }}
<div> />
<div style={{ fontWeight: 600, color: "#f3f3f5" }}>{o.label}</div> <div>
<div style={{ fontFamily: mono, fontSize: 11, color: "#8a8a92" }}>{o.hint}</div> <div style={{ fontWeight: 600, color: "#f3f3f5" }}>{o.label}</div>
</div> <div style={{ fontFamily: mono, fontSize: 11, color: "#8a8a92" }}>{o.hint}</div>
</label> </div>
))} </label>
))}
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<span style={labelStyle}>Topology (how the team runs)</span>
<p style={{ fontFamily: mono, fontSize: 11, color: "#8a8a92", lineHeight: 1.55, margin: 0 }}>
Shapes the coordinator prompt and the graph edges. For a
research → distill → analyze → implement roster, pick
<span style={{ color: "#f3f3f5" }}> Pipeline</span>. For
domain specialists working under a single hub, keep
<span style={{ color: "#f3f3f5" }}> Hub · spoke</span>.
</p>
{TOPOLOGIES.map((t) => (
<label
key={t.kind}
style={{
display: "flex",
gap: 10,
padding: "10px 12px",
borderRadius: 10,
border: `1px solid ${topology === t.kind ? "rgba(201,138,240,.5)" : "rgba(255,255,255,.1)"}`,
background: topology === t.kind ? "rgba(201,138,240,.08)" : "transparent",
cursor: "pointer",
}}
>
<input
type="radio"
name="topology"
checked={topology === t.kind}
onChange={() => setTopology(t.kind)}
style={{ marginTop: 2 }}
/>
<div>
<div style={{ fontWeight: 600, color: "#f3f3f5" }}>{t.label}</div>
<div style={{ fontFamily: mono, fontSize: 11, color: "#8a8a92", lineHeight: 1.5 }}>{t.hint}</div>
</div>
</label>
))}
</div>
</div> </div>
)} )}
+17
View File
@@ -22,6 +22,17 @@ export interface AgentSlot {
role_slot: string | null; role_slot: string | null;
} }
export type TopologyKind = "hub_spoke" | "pipeline" | "hierarchical" | "star_moe";
export interface ResearchOutcome {
id: string;
topic_id: string;
version: number;
body_md: string;
produced_by_run_id: string | null;
created_at: string;
}
export interface TopicDetail { export interface TopicDetail {
id: string; id: string;
workspace_id: string; workspace_id: string;
@@ -33,11 +44,16 @@ export interface TopicDetail {
created_at: string; created_at: string;
updated_at: string; updated_at: string;
published_at: string | null; published_at: string | null;
topology_kind: TopologyKind;
agents: AgentSlot[]; agents: AgentSlot[];
/** True while a publish approval is pending. Frontend hides the /** True while a publish approval is pending. Frontend hides the
* "Request publish" button and shows "Awaiting reviewer approval" * "Request publish" button and shows "Awaiting reviewer approval"
* instead so double-clicks don't 409. */ * instead so double-clicks don't 409. */
has_pending_publish_request: boolean; has_pending_publish_request: boolean;
/** The most recent artifact the pipeline produced for this topic.
* Present once a run has completed; the canvas renders body_md
* in place of description in reviewing/publishing/published. */
latest_outcome: ResearchOutcome | null;
} }
export interface PublishApproval { export interface PublishApproval {
@@ -77,6 +93,7 @@ export const createTopic = (body: {
title: string; title: string;
description: string; description: string;
outcome_kind: OutcomeKind; outcome_kind: OutcomeKind;
topology_kind?: TopologyKind;
agents: { agent_id: string; role_slot?: string }[]; agents: { agent_id: string; role_slot?: string }[];
repo?: TopicRepoRef | null; repo?: TopicRepoRef | null;
}) => }) =>
@@ -0,0 +1,29 @@
-- Research pipeline v2: topology_kind on the topic + a persisted outcome per
-- run so `reviewing`/`publishing`/`published` states have an artifact to
-- point at instead of the original prompt.
--
-- topology_kind — the graph shape start_topic builds when firing this
-- topic. Default 'hub_spoke' matches the prior implicit behavior.
-- Wizard exposes: hub_spoke, pipeline, hierarchical, star_moe. The
-- coordinator prompt in start_topic branches on this string.
--
-- research_outcomes — one row per run's final synthesis. Versioned per
-- topic (1, 2, …) so reject-with-revision loops keep history. body_md
-- is the coordinator's final_output verbatim; produced_by_run_id
-- back-links to the topology_runs row that made it (nullable + SET
-- NULL cascade so an old run being pruned doesn't drop the artifact).
ALTER TABLE research_topics
ADD COLUMN topology_kind TEXT NOT NULL DEFAULT 'hub_spoke';
CREATE TABLE research_outcomes (
id UUID PRIMARY KEY,
topic_id UUID NOT NULL REFERENCES research_topics (id) ON DELETE CASCADE,
version INTEGER NOT NULL,
body_md TEXT NOT NULL,
produced_by_run_id UUID REFERENCES topology_runs (id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (topic_id, version)
);
CREATE INDEX research_outcomes_topic_idx
ON research_outcomes (topic_id, version DESC);