research-wizard: schedule step (once/nightly/manual) + paired coding loop
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:
@@ -137,6 +137,22 @@ pub struct CreateTopicRequest {
|
|||||||
/// for its own UI and is ignored here.
|
/// for its own UI and is ignored here.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub repo: Option<TopicRepoRef>,
|
pub repo: Option<TopicRepoRef>,
|
||||||
|
/// D1 fold — when set, the handler also materializes a
|
||||||
|
/// kind='research' loop bound to this topic that owns the runs.
|
||||||
|
/// Omit for backwards-compat (topic behaves like the legacy
|
||||||
|
/// one-shot flow).
|
||||||
|
#[serde(default)]
|
||||||
|
pub schedule: Option<TopicSchedule>,
|
||||||
|
/// D1 fold — when true (and schedule is set), also create a
|
||||||
|
/// kind='exec' loop bound to this topic with on_artifact_update.
|
||||||
|
#[serde(default)]
|
||||||
|
pub create_paired_coding_loop: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct TopicSchedule {
|
||||||
|
/// "once" | "nightly" | "manual".
|
||||||
|
pub mode: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
// The wizard sends a denormalized display object for its own UI. Only
|
// The wizard sends a denormalized display object for its own UI. Only
|
||||||
@@ -488,9 +504,131 @@ pub async fn create_topic(
|
|||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// D1 fold — when the wizard picked a schedule, materialize the
|
||||||
|
// paired kind='research' loop that owns runs. Best-effort per
|
||||||
|
// loop: a loop-create failure logs but the topic still lands so
|
||||||
|
// the user can retry from the sidebar.
|
||||||
|
if let Some(sched) = &body.schedule {
|
||||||
|
materialize_topic_loops(
|
||||||
|
&state.pool,
|
||||||
|
user.workspace_id.as_uuid(),
|
||||||
|
user.user_id.as_uuid(),
|
||||||
|
id,
|
||||||
|
&body.title,
|
||||||
|
&sched.mode,
|
||||||
|
body.create_paired_coding_loop,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
Ok((StatusCode::CREATED, Json(TopicCreated { id })))
|
Ok((StatusCode::CREATED, Json(TopicCreated { id })))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Creates the paired research + optional coding loops for a topic
|
||||||
|
/// (D1 fold). Fails soft — logs and returns, letting the topic land
|
||||||
|
/// even if loop creation stumbles. Skips the empty-roster gate because
|
||||||
|
/// create_topic already verified the workspace has agents.
|
||||||
|
async fn materialize_topic_loops(
|
||||||
|
pool: &sqlx::PgPool,
|
||||||
|
workspace_id: Uuid,
|
||||||
|
created_by: Uuid,
|
||||||
|
topic_id: Uuid,
|
||||||
|
topic_title: &str,
|
||||||
|
mode: &str,
|
||||||
|
also_coding: bool,
|
||||||
|
) {
|
||||||
|
use serde_json::json;
|
||||||
|
// Empty graph — research loop iterations build the coordinator
|
||||||
|
// task on the fly from the topic config (compose_research_iteration_task);
|
||||||
|
// graph is a placeholder the run driver requires.
|
||||||
|
let graph = json!({ "nodes": [], "edges": [] });
|
||||||
|
|
||||||
|
// Research loop triggers by mode.
|
||||||
|
let (r_triggers, next_fire_at) = match mode {
|
||||||
|
"nightly" => (
|
||||||
|
json!({ "initial_burst": 1, "cron": "0 3 * * *" }),
|
||||||
|
cm_runtime::scheduling::next_occurrence("0 3 * * *", time::OffsetDateTime::now_utc())
|
||||||
|
.ok(),
|
||||||
|
),
|
||||||
|
"manual" => (json!({ "webhook_enabled": true }), None),
|
||||||
|
_ => (json!({ "initial_burst": 1 }), None),
|
||||||
|
};
|
||||||
|
let r_title = format!("Research · {topic_title}");
|
||||||
|
let r_loop = cm_db::repo::loops::create(
|
||||||
|
pool,
|
||||||
|
cm_db::repo::loops::NewLoop {
|
||||||
|
workspace_id,
|
||||||
|
title: &r_title,
|
||||||
|
description: "Auto-created by the research wizard. Kind=research; each iteration \
|
||||||
|
appends a new research_outcomes version for the bound topic.",
|
||||||
|
graph: &graph,
|
||||||
|
task_template: "Refresh the topic's research per the outcome kind.",
|
||||||
|
triggers: &r_triggers,
|
||||||
|
repeat_policy: &json!({ "kind": "infinite" }),
|
||||||
|
enabled: true,
|
||||||
|
next_fire_at,
|
||||||
|
webhook_token: None,
|
||||||
|
webhook_signing_key: None,
|
||||||
|
created_by,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
match r_loop {
|
||||||
|
Ok(loop_id) => {
|
||||||
|
let _ = cm_db::repo::loops::set_source_research_topic(
|
||||||
|
pool,
|
||||||
|
loop_id,
|
||||||
|
workspace_id,
|
||||||
|
Some(topic_id),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let _ = cm_db::repo::loops::set_kind(pool, loop_id, "research").await;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("materialize_topic_loops: research loop create failed: {e:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional coding loop — kind='exec', wakes on artifact updates.
|
||||||
|
if also_coding {
|
||||||
|
let c_title = format!("Coding · {topic_title}");
|
||||||
|
let c_triggers = json!({ "on_artifact_update": true, "initial_burst": 1 });
|
||||||
|
match cm_db::repo::loops::create(
|
||||||
|
pool,
|
||||||
|
cm_db::repo::loops::NewLoop {
|
||||||
|
workspace_id,
|
||||||
|
title: &c_title,
|
||||||
|
description: "Auto-created by the research wizard. Consumes one INT-XX per \
|
||||||
|
iteration from the paired research topic's artifact.",
|
||||||
|
graph: &graph,
|
||||||
|
task_template: "Execute the next unconsumed INT-XX from the artifact.",
|
||||||
|
triggers: &c_triggers,
|
||||||
|
repeat_policy: &json!({ "kind": "infinite" }),
|
||||||
|
enabled: true,
|
||||||
|
next_fire_at: None,
|
||||||
|
webhook_token: None,
|
||||||
|
webhook_signing_key: None,
|
||||||
|
created_by,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(loop_id) => {
|
||||||
|
let _ = cm_db::repo::loops::set_source_research_topic(
|
||||||
|
pool,
|
||||||
|
loop_id,
|
||||||
|
workspace_id,
|
||||||
|
Some(topic_id),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("materialize_topic_loops: coding loop create failed: {e:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
pub struct TopicListItem {
|
pub struct TopicListItem {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
|
|||||||
@@ -322,6 +322,19 @@ pub async fn set_zeroclaw_container(
|
|||||||
/// downstream mini-timeline can show WHEN the plan was adjusted and
|
/// downstream mini-timeline can show WHEN the plan was adjusted and
|
||||||
/// WHY. Idempotent: appending a duplicate text/run_id combo is allowed
|
/// WHY. Idempotent: appending a duplicate text/run_id combo is allowed
|
||||||
/// (rare — indicates the parser matched twice on the same line).
|
/// (rare — indicates the parser matched twice on the same line).
|
||||||
|
/// Set a loop's kind. Used by materialize_topic_loops right after
|
||||||
|
/// create() — the create path doesn't take a kind parameter (default
|
||||||
|
/// 'exec' matches every legacy loop), so research-kind loops flip the
|
||||||
|
/// column in a follow-up UPDATE.
|
||||||
|
pub async fn set_kind(pool: &PgPool, loop_id: Uuid, kind: &str) -> Result<(), DbError> {
|
||||||
|
sqlx::query("UPDATE loops SET kind = $2, updated_at = now() WHERE id = $1")
|
||||||
|
.bind(loop_id)
|
||||||
|
.bind(kind)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Set the countdown for the triggers.initial_burst quota. On
|
/// Set the countdown for the triggers.initial_burst quota. On
|
||||||
/// create_loop we set this to `burst - 1` after firing the first
|
/// create_loop we set this to `burst - 1` after firing the first
|
||||||
/// iteration inline; on each subsequent completion we decrement and,
|
/// iteration inline; on each subsequent completion we decrement and,
|
||||||
|
|||||||
@@ -110,7 +110,16 @@ export function ResearchWizard({
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onCreated: (topicId: string) => 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 [prompt, setPrompt] = useState("");
|
||||||
const [repo, setRepo] = useState<PickedRepo | null>(null);
|
const [repo, setRepo] = useState<PickedRepo | null>(null);
|
||||||
const [outcome, setOutcome] = useState<OutcomeKind>("spec");
|
const [outcome, setOutcome] = useState<OutcomeKind>("spec");
|
||||||
@@ -156,6 +165,8 @@ export function ResearchWizard({
|
|||||||
role_slot: s.role_slot.trim() || undefined,
|
role_slot: s.role_slot.trim() || undefined,
|
||||||
})),
|
})),
|
||||||
...(repo ? { repo } : {}),
|
...(repo ? { repo } : {}),
|
||||||
|
schedule: { mode: scheduleMode },
|
||||||
|
create_paired_coding_loop: pairedCodingLoop,
|
||||||
});
|
});
|
||||||
onCreated(id);
|
onCreated(id);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -170,7 +181,8 @@ export function ResearchWizard({
|
|||||||
step === 2 ||
|
step === 2 ||
|
||||||
(step === 3 && title.trim().length > 0 && description.trim().length > 0) ||
|
(step === 3 && title.trim().length > 0 && description.trim().length > 0) ||
|
||||||
step === 4 ||
|
step === 4 ||
|
||||||
step === 5;
|
step === 5 ||
|
||||||
|
step === 6;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -503,6 +515,97 @@ export function ResearchWizard({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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'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'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>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
@@ -518,16 +621,16 @@ export function ResearchWizard({
|
|||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="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}
|
disabled={step === 1}
|
||||||
style={{ ...secondaryBtn, opacity: step === 1 ? 0.4 : 1 }}
|
style={{ ...secondaryBtn, opacity: step === 1 ? 0.4 : 1 }}
|
||||||
>
|
>
|
||||||
Back
|
Back
|
||||||
</button>
|
</button>
|
||||||
{step < 5 ? (
|
{step < 6 ? (
|
||||||
<button
|
<button
|
||||||
type="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}
|
disabled={!canNext || refining}
|
||||||
style={{ ...primaryBtn, opacity: !canNext || refining ? 0.4 : 1 }}
|
style={{ ...primaryBtn, opacity: !canNext || refining ? 0.4 : 1 }}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -100,6 +100,19 @@ export const createTopic = (body: {
|
|||||||
topology_kind?: TopologyKind;
|
topology_kind?: TopologyKind;
|
||||||
agents: { agent_id: string; role_slot?: string }[];
|
agents: { agent_id: string; role_slot?: string }[];
|
||||||
repo?: TopicRepoRef | null;
|
repo?: TopicRepoRef | null;
|
||||||
|
/** Schedule mode picked in step 6 of the wizard. When set, the
|
||||||
|
* handler materializes a kind='research' loop bound to the topic
|
||||||
|
* with triggers driven by mode:
|
||||||
|
* once → initial_burst: 1
|
||||||
|
* nightly → initial_burst: 1 + cron "0 3 * * *"
|
||||||
|
* manual → webhook_enabled: true
|
||||||
|
* Omitting this leaves the topic in the legacy one-shot state
|
||||||
|
* (backwards compatible with older wizard versions). */
|
||||||
|
schedule?: { mode: "once" | "nightly" | "manual" };
|
||||||
|
/** When true, also create a kind='exec' loop bound to the same
|
||||||
|
* topic with `on_artifact_update: true` so it consumes each new
|
||||||
|
* artifact version one INT-XX at a time. */
|
||||||
|
create_paired_coding_loop?: boolean;
|
||||||
}) =>
|
}) =>
|
||||||
api<{ id: string }>("/api/research", {
|
api<{ id: string }>("/api/research", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
Reference in New Issue
Block a user