research pipeline v2: topology-aware start + persisted draft
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:
+9
-3
@@ -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"
|
||||||
}
|
}
|
||||||
+3
-2
@@ -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"
|
||||||
}
|
}
|
||||||
+55
@@ -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"
|
||||||
|
}
|
||||||
+9
-3
@@ -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"
|
||||||
}
|
}
|
||||||
+22
@@ -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"
|
||||||
|
}
|
||||||
+52
@@ -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"
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
Reference in New Issue
Block a user