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:
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user