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
View File
@@ -18,6 +18,7 @@ pub mod orgs;
pub mod outbox;
pub mod repo_connections;
pub mod repos;
pub mod research_outcomes;
pub mod research_publish_approvals;
pub mod research_topics;
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 updated_at: 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)]
@@ -39,18 +42,20 @@ pub async fn create(
title: &str,
description: &str,
outcome_kind: &str,
topology_kind: &str,
created_by: Uuid,
) -> Result<Uuid, DbError> {
let id = Uuid::now_v7();
sqlx::query!(
"INSERT INTO research_topics
(id, workspace_id, title, description, outcome_kind, status, created_by)
VALUES ($1, $2, $3, $4, $5, 'standby', $6)",
(id, workspace_id, title, description, outcome_kind, topology_kind, status, created_by)
VALUES ($1, $2, $3, $4, $5, $6, 'standby', $7)",
id,
workspace_id,
title,
description,
outcome_kind,
topology_kind,
created_by,
)
.execute(pool)
@@ -63,7 +68,7 @@ pub async fn list(pool: &PgPool, workspace_id: Uuid) -> Result<Vec<ResearchTopic
let rows = sqlx::query_as!(
ResearchTopic,
"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
WHERE workspace_id = $1
ORDER BY updated_at DESC",
@@ -82,7 +87,7 @@ pub async fn get(
let row = sqlx::query_as!(
ResearchTopic,
"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
WHERE id = $1 AND workspace_id = $2",
id,
+13
View File
@@ -147,6 +147,19 @@ pub async fn enqueue_run_for_team(
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
/// stored so `notify_run_completed` can flip the owning topic
/// `processing → reviewing` when its last run terminates (see