loops: bridge research artifact into loop iterations (option C + b)
ci / gates (push) Successful in 7s
ci / frontend (push) Successful in 37s
ci / rust (push) Failing after 13m41s
ci / e2e (push) Has been skipped
ci / publish (push) Has been skipped

The bridge lets a coding loop "consume" an integrations research
artifact one INT-XX item per iteration. Options b (order-sequential
iteration) and C (snapshot in task_template + save the pointer for
future refresh) from the design discussion.

Migration 0042 — three new loops columns:
- source_research_topic_id — nullable pointer to research_topics.
- consumed_int_ids TEXT[]   — INT-XX ids the loop has completed.
  Advances when topology_worker parses "COMPLETED: INT-<NN>" markers
  from the run's final output (wired in a follow-up commit).
- current_int_index INT     — monotonic pointer for order-sequential
  iteration. Coordinator addresses INT-<current+1> unless prereqs are
  unmet, in which case it works on the smallest unblocking INT-XX and
  logs the reorder rationale.

Backend:
- cm_db::repo::loops::set_source_research_topic — bind/unbind pointer.
- cm_db::repo::loops::source_research_context   — read pointer + state.
- routes::loops::compose_iteration_task         — new caller-side helper
  that reads the pointer, fetches the topic's latest research_outcome,
  and prepends the artifact + focus instruction to task_template.
- run_now + webhook_receive both pass task_template through
  compose_iteration_task before enqueue. Standalone loops (no pointer)
  behave identically to before.
- CreateLoopRequest accepts `source_research_topic_id`, ownership-
  checked via research_topics::get before persist.

Frontend:
- New ResearchArtifactPicker modal — lists published topics, fetches
  the artifact on pick, returns (topic_id, markdown) to caller.
- LoopsWizard task_template step gains "Import from research artifact"
  button (right-aligned). Click opens the picker. On pick: task
  populates with the artifact markdown, pointer saved, textarea
  expands to 8 rows, small info strip shows "Loop is bound to topic
  <id>. Each iteration will focus on the next unconsumed INT-XX."
- Unlink button reverts to standalone loop mode.

Follow-up (next commit):
- topology_worker completion hook — parse "COMPLETED: INT-<NN>" out
  of the run's final output + update consumed_int_ids +
  current_int_index atomically. Without this, current_int_index stays
  at 0 forever and every iteration works on the same INT.
- Loop card refresh button — re-read source topic's latest outcome
  (useful after a reject-with-revision cycle on the source topic).
This commit is contained in:
Omar Sobh
2026-07-09 18:47:17 -07:00
parent 3ed1d03d2b
commit ce73abe5ab
6 changed files with 428 additions and 4 deletions
@@ -32,6 +32,7 @@ import {
LoopStaffingStep,
type AgentSelection,
} from "./LoopStaffingStep";
import { ResearchArtifactPicker } from "./ResearchArtifactPicker";
const mono =
"ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace";
@@ -75,6 +76,8 @@ export function LoopsWizard({
const [description, setDescription] = useState(initial?.description ?? "");
const [repo, setRepo] = useState<PickedRepo | null>(null);
const [task, setTask] = useState(initial?.task_template ?? "");
const [sourceTopicId, setSourceTopicId] = useState<string | null>(null);
const [pickerOpen, setPickerOpen] = useState(false);
// When editing an existing loop, start in advanced mode so the caller sees
// the exact graph they saved. Builder mode would rebuild + overwrite it.
const [topologyMode, setTopologyMode] = useState<"builder" | "advanced">(
@@ -262,6 +265,7 @@ export function LoopsWizard({
},
repeat_policy,
...(repo ? { repo } : {}),
...(sourceTopicId ? { source_research_topic_id: sourceTopicId } : {}),
...staffing,
};
if (editing && initial) {
@@ -376,15 +380,58 @@ export function LoopsWizard({
{!noAgents && step === 3 && (
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<label style={labelStyle} htmlFor="loop-task">Task template</label>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<label style={labelStyle} htmlFor="loop-task">Task template</label>
<button
type="button"
onClick={() => setPickerOpen(true)}
style={{
padding: "6px 12px",
borderRadius: 6,
border: `1px solid ${sourceTopicId ? "rgba(94,200,216,.5)" : "rgba(255,255,255,.15)"}`,
background: sourceTopicId ? "rgba(94,200,216,.08)" : "transparent",
color: sourceTopicId ? "#5ec8d8" : "#c3c3c8",
fontSize: 11,
fontFamily: mono,
cursor: "pointer",
}}
title="Populate task_template from a published research artifact"
>
{sourceTopicId ? "✓ From research" : "Import from research artifact"}
</button>
</div>
<textarea
id="loop-task"
value={task}
onChange={(e) => setTask(e.target.value)}
rows={4}
rows={sourceTopicId ? 8 : 4}
placeholder="What should the loop's agents do each iteration?"
style={fieldStyle}
/>
{sourceTopicId ? (
<div style={{ display: "flex", alignItems: "center", gap: 8, fontSize: 10.5, fontFamily: mono, color: "#8a8a92" }}>
<span>
Loop is bound to research topic {sourceTopicId.slice(0, 8)}. Each
iteration will prepend the topic&apos;s latest artifact + focus on
the next unconsumed INT-XX item.
</span>
<button
type="button"
onClick={() => setSourceTopicId(null)}
style={{
padding: "2px 8px",
borderRadius: 4,
border: "1px solid rgba(255,138,122,.4)",
background: "transparent",
color: "#ff8a7a",
fontSize: 10,
cursor: "pointer",
}}
>
Unlink
</button>
</div>
) : null}
<div
style={{
@@ -661,6 +708,16 @@ export function LoopsWizard({
</div>
)}
</div>
{pickerOpen ? (
<ResearchArtifactPicker
onPick={({ topicId, artifactMd }) => {
setSourceTopicId(topicId);
setTask(artifactMd);
setPickerOpen(false);
}}
onClose={() => setPickerOpen(false)}
/>
) : null}
</div>
);
}
@@ -0,0 +1,206 @@
"use client";
// Modal for the loop wizard's "Import from research artifact" flow.
// Lists published research topics that have at least one outcome row,
// user picks one → fetches the artifact markdown → returns
// (topic_id, markdown) to the parent, which snapshots it into the
// loop's task_template AND records the pointer so future iterations
// can auto-refresh from the source topic.
import { useEffect, useState } from "react";
import { X } from "lucide-react";
import { listTopics, type TopicListItem } from "@/lib/api/research";
const mono =
"ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace";
export interface PickedArtifact {
topicId: string;
artifactMd: string;
}
export function ResearchArtifactPicker({
onPick,
onClose,
}: {
onPick: (a: PickedArtifact) => void;
onClose: () => void;
}) {
const [topics, setTopics] = useState<TopicListItem[]>([]);
const [loading, setLoading] = useState(true);
const [busyId, setBusyId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let alive = true;
(async () => {
try {
const rows = await listTopics();
if (alive) {
// Only topics that reached 'published' — they have an artifact
// downstream loops can consume.
setTopics(rows.filter((t) => t.status === "published"));
}
} catch {
if (alive) setError("Could not load research topics");
} finally {
if (alive) setLoading(false);
}
})();
return () => {
alive = false;
};
}, []);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
async function pick(t: TopicListItem) {
if (busyId) return;
setBusyId(t.id);
setError(null);
try {
const res = await fetch(`/api/research/${t.id}/artifact`);
if (!res.ok) {
setError(`Artifact not available (${res.status})`);
setBusyId(null);
return;
}
const artifactMd = await res.text();
onPick({ topicId: t.id, artifactMd });
} catch {
setError("Could not fetch artifact");
setBusyId(null);
}
}
return (
<div
onClick={onClose}
role="presentation"
style={{
position: "fixed",
inset: 0,
zIndex: 120,
background: "rgba(0,0,0,.68)",
backdropFilter: "blur(4px)",
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: 24,
}}
>
<div
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-label="Import research artifact"
style={{
width: "100%",
maxWidth: 560,
maxHeight: "80vh",
display: "flex",
flexDirection: "column",
borderRadius: 16,
background: "#0d0d10",
border: "1px solid rgba(255,255,255,.1)",
boxShadow: "0 30px 90px rgba(0,0,0,.6)",
padding: 22,
}}
>
<div style={{ display: "flex", alignItems: "flex-start", gap: 12, marginBottom: 12 }}>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 17, fontWeight: 700, color: "#f3f3f5" }}>
Import from research artifact
</div>
<div style={{ fontSize: 12, color: "#8a8a92", marginTop: 2 }}>
Pick a published topic. Its artifact will fill the task template,
and each iteration will focus on the next unconsumed INT-XX item.
</div>
</div>
<button
type="button"
onClick={onClose}
aria-label="Close"
style={{
width: 30,
height: 30,
flex: "none",
borderRadius: 8,
border: "1px solid rgba(255,255,255,.12)",
background: "transparent",
color: "#9a9aa2",
cursor: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<X size={15} />
</button>
</div>
<div style={{ flex: 1, minHeight: 0, overflowY: "auto", display: "flex", flexDirection: "column", gap: 6 }}>
{loading ? (
<div style={{ padding: 24, textAlign: "center", fontFamily: mono, fontSize: 11, color: "#8a8a92" }}>
Loading
</div>
) : topics.length === 0 ? (
<div style={{ padding: 24, textAlign: "center", fontFamily: mono, fontSize: 11, color: "#8a8a92", lineHeight: 1.6 }}>
No published topics yet.
<br />
Publish one from the Research tab first.
</div>
) : (
topics.map((t) => (
<button
key={t.id}
type="button"
onClick={() => pick(t)}
disabled={busyId === t.id}
style={{
display: "flex",
flexDirection: "column",
alignItems: "flex-start",
gap: 4,
padding: "10px 12px",
borderRadius: 10,
border: "1px solid rgba(255,255,255,.08)",
background: "#101014",
color: "#eaeaee",
cursor: busyId === t.id ? "wait" : "pointer",
textAlign: "left",
}}
>
<div style={{ fontWeight: 600, fontSize: 13 }}>{t.title}</div>
<div style={{ fontFamily: mono, fontSize: 10, color: "#8a8a92" }}>
{t.outcome_kind.replace("_", " ")} · published
</div>
</button>
))
)}
</div>
{error ? (
<div
role="alert"
style={{
marginTop: 12,
fontFamily: mono,
fontSize: 10.5,
color: "#ff8a7a",
}}
>
{error}
</div>
) : null}
</div>
</div>
);
}