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.
|
||||
#[serde(default)]
|
||||
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
|
||||
@@ -488,9 +504,131 @@ pub async fn create_topic(
|
||||
.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 })))
|
||||
}
|
||||
|
||||
/// 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)]
|
||||
pub struct TopicListItem {
|
||||
pub id: Uuid,
|
||||
|
||||
Reference in New Issue
Block a user