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
@@ -14,6 +14,7 @@ import {
createTopic,
wizardRefine,
type OutcomeKind,
type TopologyKind,
} from "@/lib/api/research";
import { RepoPicker, type PickedRepo } from "./RepoPicker";
import { NoAgentsGate } from "./NoAgentsGate";
@@ -21,6 +22,29 @@ import { NoAgentsGate } from "./NoAgentsGate";
const mono =
"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 }[] = [
{ kind: "spec", label: "Spec", hint: "A structured technical specification." },
{
@@ -54,6 +78,7 @@ export function ResearchWizard({
const [prompt, setPrompt] = useState("");
const [repo, setRepo] = useState<PickedRepo | null>(null);
const [outcome, setOutcome] = useState<OutcomeKind>("spec");
const [topology, setTopology] = useState<TopologyKind>("hub_spoke");
const [refining, setRefining] = useState(false);
const [refineError, setRefineError] = useState<string | null>(null);
const [title, setTitle] = useState("");
@@ -89,6 +114,7 @@ export function ResearchWizard({
title: title.trim(),
description: description.trim(),
outcome_kind: outcome,
topology_kind: topology,
agents: selected.map((s) => ({
agent_id: s.agent_id,
role_slot: s.role_slot.trim() || undefined,
@@ -268,34 +294,73 @@ export function ResearchWizard({
)}
{agents.length > 0 && step === 4 && (
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<span style={labelStyle}>Outcome kind</span>
{OUTCOMES.map((o) => (
<label
key={o.kind}
style={{
display: "flex",
gap: 10,
padding: "10px 12px",
borderRadius: 10,
border: `1px solid ${outcome === o.kind ? "rgba(255,111,97,.5)" : "rgba(255,255,255,.1)"}`,
background: outcome === o.kind ? "rgba(255,111,97,.08)" : "transparent",
cursor: "pointer",
}}
>
<input
type="radio"
name="outcome"
checked={outcome === o.kind}
onChange={() => setOutcome(o.kind)}
style={{ marginTop: 2 }}
/>
<div>
<div style={{ fontWeight: 600, color: "#f3f3f5" }}>{o.label}</div>
<div style={{ fontFamily: mono, fontSize: 11, color: "#8a8a92" }}>{o.hint}</div>
</div>
</label>
))}
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<span style={labelStyle}>Outcome kind</span>
{OUTCOMES.map((o) => (
<label
key={o.kind}
style={{
display: "flex",
gap: 10,
padding: "10px 12px",
borderRadius: 10,
border: `1px solid ${outcome === o.kind ? "rgba(255,111,97,.5)" : "rgba(255,255,255,.1)"}`,
background: outcome === o.kind ? "rgba(255,111,97,.08)" : "transparent",
cursor: "pointer",
}}
>
<input
type="radio"
name="outcome"
checked={outcome === o.kind}
onChange={() => setOutcome(o.kind)}
style={{ marginTop: 2 }}
/>
<div>
<div style={{ fontWeight: 600, color: "#f3f3f5" }}>{o.label}</div>
<div style={{ fontFamily: mono, fontSize: 11, color: "#8a8a92" }}>{o.hint}</div>
</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>
)}