loops: bridge research artifact into loop iterations (option C + b)
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:
@@ -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'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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user