research-canvas: managed-by-loop UI + start_topic guard (fold cleanup)
Closes the UX gap the fold introduced: the topic canvas was still showing "Start research" for standby-state topics even when a scheduled loop already owned the runs. Clicking it would 409 (or worse: race the loop into a duplicate run). Topic status stayed at standby forever because the loop path bypassed start_topic's set_status transition. Four changes: 1. **Backend status transition** — compose_and_enqueue_iteration for kind='research' now calls set_status_if(standby, processing) on the topic before the run is enqueued. New DB helper set_status_if only advances when the current status matches the "from" arg — safe against races and re-invocations. Later iterations no-op since the topic is already past standby. 2. **has_managed_loop on TopicDetail** — get_topic hydrates a new ManagedLoop struct (loop_id, title, enabled, next_fire_at, last_run_id, schedule_summary) when a kind='research' loop is bound to the topic. summarize_schedule() derives a human string from the loop's triggers jsonb (e.g. "cron: 0 3 * * * · on new artifact", "one-shot", "manual"). New DB helper loops::research_loop_for_topic returns the row. 3. **Canvas branch** — nextAction takes a managedByLoop flag; when set + status=standby, returns null (no button). The canvas renders a "MANAGED BY LOOP" strip below the topic title showing loop name, schedule summary, next fire time, and enabled dot. Reviewer buttons (Request publish / Approve / Reject) still show normally in later states — reviewers should still promote outcomes even when a loop is producing them. 4. **start_topic guard** — refuses with 409 when a research loop already owns the topic. Closes the direct-POST hole for anyone bypassing the frontend. TS type + summarize_schedule live in the same commit so an old client hitting a new backend just ignores the extra field (no breakage), and a new client hitting an old backend renders the classic buttons (managed_by_loop is optional).
This commit is contained in:
@@ -95,6 +95,20 @@ pub async fn compose_and_enqueue_iteration(
|
||||
// second iteration reattaches to the existing container. Runs
|
||||
// even when the topic has no repo (harmless no-op).
|
||||
crate::routes::research_setup::prepare_topic_runtime(pool, workspace_id, topic_id).await;
|
||||
// D1 fold — advance the topic's status column when a fresh
|
||||
// research iteration goes out so the canvas's classic state-
|
||||
// machine card reflects reality. Only fire the standby →
|
||||
// processing transition; later iterations already sit in
|
||||
// processing/reviewing/publishing and set_status is a no-op
|
||||
// when the status is already the target.
|
||||
let _ = cm_db::repo::research_topics::set_status_if(
|
||||
pool,
|
||||
topic_id,
|
||||
workspace_id,
|
||||
"standby",
|
||||
"processing",
|
||||
)
|
||||
.await;
|
||||
let task = compose_research_iteration_task(pool, topic_id, template_ref).await;
|
||||
cm_db::repo::loops::enqueue_iteration_with_topic(
|
||||
pool,
|
||||
|
||||
@@ -450,6 +450,26 @@ pub struct TopicDetail {
|
||||
/// spinner and hides the manual "Submit for review" button, which is
|
||||
/// only offered when this is 0 (as an escape hatch for stalled runs).
|
||||
pub runs_in_flight: i64,
|
||||
/// The wizard-materialized research loop that owns this topic's runs
|
||||
/// (D1 fold). Populated when a kind='research' loop exists with
|
||||
/// source_research_topic_id = this topic. Frontend uses this to
|
||||
/// hide the classic "Start research" button and instead show a
|
||||
/// "Managed by scheduled loop" strip.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub managed_by_loop: Option<ManagedLoop>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ManagedLoop {
|
||||
pub loop_id: Uuid,
|
||||
pub title: String,
|
||||
pub enabled: bool,
|
||||
pub next_fire_at: Option<time::OffsetDateTime>,
|
||||
pub last_run_id: Option<Uuid>,
|
||||
/// Human-readable schedule summary derived from the loop's
|
||||
/// triggers jsonb — e.g. "Nightly (cron: 0 3 * * *)", "Manual",
|
||||
/// "Once at create". Convenience for the canvas strip.
|
||||
pub schedule_summary: String,
|
||||
}
|
||||
|
||||
pub async fn get_topic(
|
||||
@@ -468,15 +488,73 @@ pub async fn get_topic(
|
||||
let latest_outcome = cm_db::repo::research_outcomes::latest(&state.pool, id).await?;
|
||||
let runs_in_flight =
|
||||
cm_db::repo::topology_runs::active_runs_for_research_topic(&state.pool, id).await?;
|
||||
// D1 fold — surface the wizard-materialized research loop so the
|
||||
// canvas can swap the classic state-machine buttons for the
|
||||
// "Managed by scheduled loop" strip.
|
||||
let managed_by_loop = cm_db::repo::loops::research_loop_for_topic(&state.pool, id)
|
||||
.await
|
||||
.unwrap_or(None)
|
||||
.map(
|
||||
|(loop_id, title, enabled, next_fire_at, last_run_id, triggers)| ManagedLoop {
|
||||
loop_id,
|
||||
title,
|
||||
enabled,
|
||||
next_fire_at,
|
||||
last_run_id,
|
||||
schedule_summary: summarize_schedule(&triggers),
|
||||
},
|
||||
);
|
||||
Ok(Json(TopicDetail {
|
||||
topic,
|
||||
agents,
|
||||
has_pending_publish_request,
|
||||
latest_outcome,
|
||||
runs_in_flight,
|
||||
managed_by_loop,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Human-readable one-liner for the loop's triggers jsonb — surfaces
|
||||
/// on the canvas's "Managed by scheduled loop" strip so users don't
|
||||
/// have to click through to the loops sidebar to know the cadence.
|
||||
fn summarize_schedule(triggers: &serde_json::Value) -> String {
|
||||
let cron = triggers.get("cron").and_then(|v| v.as_str());
|
||||
let webhook = triggers
|
||||
.get("webhook_enabled")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
let burst = triggers
|
||||
.get("initial_burst")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0);
|
||||
let on_artifact = triggers
|
||||
.get("on_artifact_update")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
let mut parts = Vec::new();
|
||||
if let Some(c) = cron {
|
||||
parts.push(format!("cron: {c}"));
|
||||
}
|
||||
if webhook {
|
||||
parts.push("webhook".into());
|
||||
}
|
||||
if on_artifact {
|
||||
parts.push("on new artifact".into());
|
||||
}
|
||||
if burst > 0 && cron.is_none() && !webhook {
|
||||
parts.push(if burst == 1 {
|
||||
"one-shot".into()
|
||||
} else {
|
||||
format!("burst of {burst}")
|
||||
});
|
||||
}
|
||||
if parts.is_empty() {
|
||||
"manual".into()
|
||||
} else {
|
||||
parts.join(" · ")
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateTopicRequest {
|
||||
pub title: String,
|
||||
@@ -582,6 +660,18 @@ pub async fn start_topic(
|
||||
if topic.status != "standby" {
|
||||
return Err(ApiError::Conflict);
|
||||
}
|
||||
// D1 fold — refuse to double-fire when a scheduled research loop
|
||||
// already owns this topic. Otherwise clicking the legacy "Start
|
||||
// research" button while a loop iteration is in flight would spawn
|
||||
// a competing run through the classic path.
|
||||
if cm_db::repo::loops::research_loop_for_topic(&state.pool, id)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.is_some()
|
||||
{
|
||||
return Err(ApiError::Conflict);
|
||||
}
|
||||
let slots = cm_db::repo::research_topics::agents(&state.pool, id).await?;
|
||||
if slots.is_empty() {
|
||||
return Err(ApiError::BadRequest);
|
||||
|
||||
Reference in New Issue
Block a user