research-wizard: schedule step (once/nightly/manual) + paired coding loop
ci / gates (push) Failing after 5s
ci / rust (push) Has been skipped
ci / frontend (push) Has been skipped
ci / e2e (push) Has been skipped
ci / publish (push) Has been skipped

Completes the research/loop fold. The wizard now closes with a
"How should this research run?" step; picking any mode materializes a
kind='research' loop bound to the topic, and an optional checkbox
adds a paired kind='exec' loop that consumes each new artifact.

Frontend:
- ResearchWizard grows from 5 to 6 steps. Step 6 is the schedule
  picker:
    · Just once — initial_burst=1, no other triggers
    · Nightly   — initial_burst=1 + cron "0 3 * * *"
    · Manual    — webhook_enabled=true
  Below the radios, an optional card offers "Also create a coding
  loop that consumes each new artifact" — creates a paired
  kind='exec' loop with on_artifact_update=true + initial_burst=1
  bound to the same topic.
- createTopic API type extended with `schedule` + `create_paired_coding_loop`.
- Both fields ride the existing POST /api/research call; back-compat
  is preserved when the wizard omits them.

Backend:
- CreateTopicRequest gains TopicSchedule + create_paired_coding_loop.
- After topic + agent attach, create_topic calls
  materialize_topic_loops which:
    1. Creates a research-kind loop titled "Research · <topic>" bound
       to the topic. Triggers vary by schedule mode; next_fire_at
       computed from cron for nightly. Falls back silently if
       loop-create errors so the topic still lands.
    2. Flips kind to 'research' via loops::set_kind (NewLoop doesn't
       take kind directly — default is 'exec' for backward compat).
    3. Optionally creates a coding loop titled "Coding · <topic>"
       with on_artifact_update=true + initial_burst=1.
- cm_db::repo::loops::set_kind — trivial UPDATE helper used by the
  materialize path.

D-answer callouts:
- D1 (fold): every runnable thing is now a loop. "Just once" is a
  research loop with initial_burst=1 and no other triggers.
- D2 (inherit): both paired loops carry the same source_research_topic_id
  — repo binding lives on the topic, not duplicated.
- D3 (coordinator resolves): the research iteration prompt (from the
  earlier commit) instructs the team to preserve stable INT ids and
  mark deprecations; coding loops' consumed lists stay valid across
  versions.

Follow-ups queued:
- Kind pill on LoopsList cards (research=purple, exec=cyan) so users
  can tell them apart at a glance.
- Extract start_topic's task-build so kind='research' iterations
  reuse the same coordinator prompt shape as one-shot runs (they
  currently use a simpler refresh-oriented prompt; that's fine for
  MVP but a rich shared build would give better parity).
- Research topic sidebar shows "linked to N loops" badge.
This commit is contained in:
Omar Sobh
2026-07-09 23:00:04 -07:00
parent 91a51dce11
commit 3e42b1ea39
4 changed files with 272 additions and 5 deletions
@@ -110,7 +110,16 @@ export function ResearchWizard({
onClose: () => void;
onCreated: (topicId: string) => void;
}) {
const [step, setStep] = useState<1 | 2 | 3 | 4 | 5>(1);
const [step, setStep] = useState<1 | 2 | 3 | 4 | 5 | 6>(1);
// Schedule (D1 fold): every research topic materializes a kind='research'
// loop that owns the runs. "Once" fires once and stops (initial_burst=1);
// "Nightly" adds cron 0 3 * * *; "Manual" only surfaces the webhook. The
// paired coding loop (optional) is a second kind='exec' loop with
// on_artifact_update=true so it consumes each new artifact version.
const [scheduleMode, setScheduleMode] = useState<"once" | "nightly" | "manual">(
"once",
);
const [pairedCodingLoop, setPairedCodingLoop] = useState(false);
const [prompt, setPrompt] = useState("");
const [repo, setRepo] = useState<PickedRepo | null>(null);
const [outcome, setOutcome] = useState<OutcomeKind>("spec");
@@ -156,6 +165,8 @@ export function ResearchWizard({
role_slot: s.role_slot.trim() || undefined,
})),
...(repo ? { repo } : {}),
schedule: { mode: scheduleMode },
create_paired_coding_loop: pairedCodingLoop,
});
onCreated(id);
} catch (e) {
@@ -170,7 +181,8 @@ export function ResearchWizard({
step === 2 ||
(step === 3 && title.trim().length > 0 && description.trim().length > 0) ||
step === 4 ||
step === 5;
step === 5 ||
step === 6;
return (
<div
@@ -503,6 +515,97 @@ export function ResearchWizard({
)}
</div>
)}
{agents.length > 0 && step === 6 && (
<div style={{ display: "flex", flexDirection: "column", gap: 14 }}>
<span style={labelStyle}>How should this research run?</span>
<p style={hintStyle}>
Every research topic materializes as a loop under the hood.
The mode below picks the loop&apos;s triggers.
</p>
{(
[
{
mode: "once" as const,
label: "Just once",
hint: "Runs immediately on create, produces one artifact, then stops.",
},
{
mode: "nightly" as const,
label: "Nightly",
hint: "Runs immediately, then again at 03:00 every day. Each run appends a new artifact version.",
},
{
mode: "manual" as const,
label: "Manual only",
hint: "No auto-fire. You trigger runs via the loop&apos;s Run-now button or webhook.",
},
]
).map((o) => (
<label
key={o.mode}
style={{
display: "flex",
gap: 10,
padding: "10px 12px",
borderRadius: 10,
border: `1px solid ${scheduleMode === o.mode ? "rgba(255,111,97,.5)" : "rgba(255,255,255,.1)"}`,
background: scheduleMode === o.mode ? "rgba(255,111,97,.08)" : "transparent",
cursor: "pointer",
}}
>
<input
type="radio"
name="schedule"
checked={scheduleMode === o.mode}
onChange={() => setScheduleMode(o.mode)}
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={{
marginTop: 8,
padding: 12,
borderRadius: 10,
border: `1px solid ${pairedCodingLoop ? "rgba(94,200,216,.4)" : "rgba(255,255,255,.08)"}`,
background: pairedCodingLoop ? "rgba(94,200,216,.06)" : "rgba(255,255,255,.02)",
}}
>
<label style={{ display: "flex", gap: 10, cursor: "pointer" }}>
<input
type="checkbox"
checked={pairedCodingLoop}
onChange={(e) => setPairedCodingLoop(e.target.checked)}
style={{ marginTop: 2 }}
/>
<div>
<div style={{ fontWeight: 600, color: "#f3f3f5" }}>
Also create a coding loop that consumes each new artifact
</div>
<div style={{ fontFamily: mono, fontSize: 11, color: "#8a8a92", lineHeight: 1.5, marginTop: 4 }}>
Adds a second (kind=exec) loop with{" "}
<code>on_artifact_update</code> = true, bound to the same
topic. Wakes up whenever the research loop writes a new
version and consumes one <code>INT-XX</code> per
iteration in order.
</div>
</div>
</label>
</div>
{submitError && (
<p style={{ fontFamily: mono, fontSize: 12, color: "#ff8a7a" }}>
{submitError}
</p>
)}
</div>
)}
</div>
{/* Footer */}
@@ -518,16 +621,16 @@ export function ResearchWizard({
>
<button
type="button"
onClick={() => setStep((s) => (s > 1 ? ((s - 1) as 1 | 2 | 3 | 4 | 5) : s))}
onClick={() => setStep((s) => (s > 1 ? ((s - 1) as 1 | 2 | 3 | 4 | 5 | 6) : s))}
disabled={step === 1}
style={{ ...secondaryBtn, opacity: step === 1 ? 0.4 : 1 }}
>
Back
</button>
{step < 5 ? (
{step < 6 ? (
<button
type="button"
onClick={() => setStep((s) => ((s + 1) as 1 | 2 | 3 | 4 | 5))}
onClick={() => setStep((s) => ((s + 1) as 1 | 2 | 3 | 4 | 5 | 6))}
disabled={!canNext || refining}
style={{ ...primaryBtn, opacity: !canNext || refining ? 0.4 : 1 }}
>