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
|
// second iteration reattaches to the existing container. Runs
|
||||||
// even when the topic has no repo (harmless no-op).
|
// even when the topic has no repo (harmless no-op).
|
||||||
crate::routes::research_setup::prepare_topic_runtime(pool, workspace_id, topic_id).await;
|
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;
|
let task = compose_research_iteration_task(pool, topic_id, template_ref).await;
|
||||||
cm_db::repo::loops::enqueue_iteration_with_topic(
|
cm_db::repo::loops::enqueue_iteration_with_topic(
|
||||||
pool,
|
pool,
|
||||||
|
|||||||
@@ -450,6 +450,26 @@ pub struct TopicDetail {
|
|||||||
/// spinner and hides the manual "Submit for review" button, which is
|
/// spinner and hides the manual "Submit for review" button, which is
|
||||||
/// only offered when this is 0 (as an escape hatch for stalled runs).
|
/// only offered when this is 0 (as an escape hatch for stalled runs).
|
||||||
pub runs_in_flight: i64,
|
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(
|
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 latest_outcome = cm_db::repo::research_outcomes::latest(&state.pool, id).await?;
|
||||||
let runs_in_flight =
|
let runs_in_flight =
|
||||||
cm_db::repo::topology_runs::active_runs_for_research_topic(&state.pool, id).await?;
|
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 {
|
Ok(Json(TopicDetail {
|
||||||
topic,
|
topic,
|
||||||
agents,
|
agents,
|
||||||
has_pending_publish_request,
|
has_pending_publish_request,
|
||||||
latest_outcome,
|
latest_outcome,
|
||||||
runs_in_flight,
|
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)]
|
#[derive(Deserialize)]
|
||||||
pub struct UpdateTopicRequest {
|
pub struct UpdateTopicRequest {
|
||||||
pub title: String,
|
pub title: String,
|
||||||
@@ -582,6 +660,18 @@ pub async fn start_topic(
|
|||||||
if topic.status != "standby" {
|
if topic.status != "standby" {
|
||||||
return Err(ApiError::Conflict);
|
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?;
|
let slots = cm_db::repo::research_topics::agents(&state.pool, id).await?;
|
||||||
if slots.is_empty() {
|
if slots.is_empty() {
|
||||||
return Err(ApiError::BadRequest);
|
return Err(ApiError::BadRequest);
|
||||||
|
|||||||
@@ -379,6 +379,51 @@ pub async fn take_initial_burst_slot(pool: &PgPool, loop_id: Uuid) -> Result<i32
|
|||||||
/// after freeze_research_outcome inserts a new row. Returns
|
/// after freeze_research_outcome inserts a new row. Returns
|
||||||
/// (loop_id, workspace_id, task_template, graph) so the caller can
|
/// (loop_id, workspace_id, task_template, graph) so the caller can
|
||||||
/// enqueue directly without a second fetch.
|
/// enqueue directly without a second fetch.
|
||||||
|
/// Return the kind='research' loop that owns a topic's runs (there
|
||||||
|
/// should be at most one — created by the wizard's
|
||||||
|
/// materialize_topic_loops). Used by the topic detail endpoint to
|
||||||
|
/// tell the canvas that classic Start/Submit buttons should be
|
||||||
|
/// replaced with the loop-managed UI.
|
||||||
|
pub async fn research_loop_for_topic(
|
||||||
|
pool: &PgPool,
|
||||||
|
topic_id: Uuid,
|
||||||
|
) -> Result<
|
||||||
|
Option<(
|
||||||
|
Uuid,
|
||||||
|
String,
|
||||||
|
bool,
|
||||||
|
Option<time::OffsetDateTime>,
|
||||||
|
Option<Uuid>,
|
||||||
|
Value,
|
||||||
|
)>,
|
||||||
|
DbError,
|
||||||
|
> {
|
||||||
|
use sqlx::Row;
|
||||||
|
let row: Option<sqlx::postgres::PgRow> = sqlx::query(
|
||||||
|
"SELECT id, title, enabled, next_fire_at, last_run_id, triggers
|
||||||
|
FROM loops
|
||||||
|
WHERE source_research_topic_id = $1
|
||||||
|
AND kind = 'research'
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 1",
|
||||||
|
)
|
||||||
|
.bind(topic_id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(row.map(|r| {
|
||||||
|
(
|
||||||
|
r.get::<Uuid, _>("id"),
|
||||||
|
r.get::<String, _>("title"),
|
||||||
|
r.get::<bool, _>("enabled"),
|
||||||
|
r.try_get::<Option<time::OffsetDateTime>, _>("next_fire_at")
|
||||||
|
.ok()
|
||||||
|
.flatten(),
|
||||||
|
r.try_get::<Option<Uuid>, _>("last_run_id").ok().flatten(),
|
||||||
|
r.get::<Value, _>("triggers"),
|
||||||
|
)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn loops_awaiting_topic(
|
pub async fn loops_awaiting_topic(
|
||||||
pool: &PgPool,
|
pool: &PgPool,
|
||||||
topic_id: Uuid,
|
topic_id: Uuid,
|
||||||
|
|||||||
@@ -153,6 +153,38 @@ pub async fn list(pool: &PgPool, workspace_id: Uuid) -> Result<Vec<ResearchTopic
|
|||||||
Ok(rows)
|
Ok(rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Conditional set_status — advance ONLY if the current status
|
||||||
|
/// matches `from`. Used by the fold hook that bumps standby →
|
||||||
|
/// processing when a research loop's first iteration goes out
|
||||||
|
/// without racing with later hooks that may have already advanced
|
||||||
|
/// the topic further. Returns silently on no match; the caller
|
||||||
|
/// treats it as best-effort.
|
||||||
|
pub async fn set_status_if(
|
||||||
|
pool: &PgPool,
|
||||||
|
id: Uuid,
|
||||||
|
workspace_id: Uuid,
|
||||||
|
from: &str,
|
||||||
|
to: &str,
|
||||||
|
) -> Result<(), DbError> {
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE research_topics
|
||||||
|
SET status = $4,
|
||||||
|
published_at = CASE
|
||||||
|
WHEN $4 = 'publishing' AND published_at IS NULL THEN now()
|
||||||
|
ELSE published_at
|
||||||
|
END,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = $1 AND workspace_id = $2 AND status = $3",
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.bind(workspace_id)
|
||||||
|
.bind(from)
|
||||||
|
.bind(to)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Cross-workspace fetch used by internal callers (topology_worker
|
/// Cross-workspace fetch used by internal callers (topology_worker
|
||||||
/// completion hooks, kind='research' loop iteration builders) where
|
/// completion hooks, kind='research' loop iteration builders) where
|
||||||
/// the caller already has an authoritative workspace binding from the
|
/// the caller already has an authoritative workspace binding from the
|
||||||
|
|||||||
@@ -57,22 +57,25 @@ function nextAction(
|
|||||||
status: TopicStatus,
|
status: TopicStatus,
|
||||||
hasPendingPublish: boolean,
|
hasPendingPublish: boolean,
|
||||||
runsInFlight: number,
|
runsInFlight: number,
|
||||||
|
managedByLoop: boolean,
|
||||||
): {
|
): {
|
||||||
label: string;
|
label: string;
|
||||||
run: (id: string) => Promise<unknown>;
|
run: (id: string) => Promise<unknown>;
|
||||||
} | null {
|
} | null {
|
||||||
|
// D1 fold — when a scheduled loop owns this topic, the loop itself
|
||||||
|
// handles start / iteration; the classic "Start research" button
|
||||||
|
// would 409 and confuse the mental model. The canvas swaps it for
|
||||||
|
// the "Managed by loop" strip rendered elsewhere in the tree.
|
||||||
|
if (managedByLoop && status === "standby") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case "standby":
|
case "standby":
|
||||||
return { label: "Start research", run: startTopic };
|
return { label: "Start research", run: startTopic };
|
||||||
case "processing":
|
case "processing":
|
||||||
// While any run is queued or executing, DON'T offer submit-for-review
|
|
||||||
// — the runner auto-transitions on completion. Only show the manual
|
|
||||||
// escape hatch when nothing's in flight (indicates a stall).
|
|
||||||
if (runsInFlight > 0) return null;
|
if (runsInFlight > 0) return null;
|
||||||
return { label: "Submit for review (manual)", run: submitReview };
|
return { label: "Submit for review (manual)", run: submitReview };
|
||||||
case "reviewing":
|
case "reviewing":
|
||||||
// Backend already has a pending approval — don't offer the button
|
|
||||||
// (a second click 409s). Frontend surfaces an "Awaiting" note below.
|
|
||||||
if (hasPendingPublish) return null;
|
if (hasPendingPublish) return null;
|
||||||
return { label: "Request publish", run: requestPublish };
|
return { label: "Request publish", run: requestPublish };
|
||||||
default:
|
default:
|
||||||
@@ -225,6 +228,7 @@ export function ResearchCanvas({
|
|||||||
topic.status,
|
topic.status,
|
||||||
topic.has_pending_publish_request,
|
topic.has_pending_publish_request,
|
||||||
topic.runs_in_flight,
|
topic.runs_in_flight,
|
||||||
|
!!topic.managed_by_loop,
|
||||||
);
|
);
|
||||||
const stageCopy = STAGE_COPY[topic.status];
|
const stageCopy = STAGE_COPY[topic.status];
|
||||||
const pipelineRunning =
|
const pipelineRunning =
|
||||||
@@ -346,6 +350,45 @@ export function ResearchCanvas({
|
|||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{topic.managed_by_loop ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 12,
|
||||||
|
padding: "10px 12px",
|
||||||
|
borderRadius: 10,
|
||||||
|
border: "1px solid rgba(94,200,216,.35)",
|
||||||
|
background: "rgba(94,200,216,.06)",
|
||||||
|
fontFamily: mono,
|
||||||
|
fontSize: 11,
|
||||||
|
}}
|
||||||
|
title={`Runs are owned by the loop "${topic.managed_by_loop.title}". The classic Start button is disabled to prevent duplicate runs.`}
|
||||||
|
>
|
||||||
|
<span style={{ color: "#5ec8d8", letterSpacing: ".08em", fontWeight: 700 }}>
|
||||||
|
MANAGED BY LOOP
|
||||||
|
</span>
|
||||||
|
<span style={{ color: "#eaeaee", flex: 1, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||||
|
{topic.managed_by_loop.title}
|
||||||
|
</span>
|
||||||
|
<span style={{ color: "#8a8a92" }}>{topic.managed_by_loop.schedule_summary}</span>
|
||||||
|
{topic.managed_by_loop.next_fire_at ? (
|
||||||
|
<span style={{ color: "#8a8a92" }}>
|
||||||
|
· next {new Date(topic.managed_by_loop.next_fire_at).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: 7,
|
||||||
|
height: 7,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: topic.managed_by_loop.enabled ? "#5fd08a" : "#5a5a62",
|
||||||
|
}}
|
||||||
|
title={topic.managed_by_loop.enabled ? "loop enabled" : "loop disabled"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{/* Pipeline diagnostics — click to expand */}
|
{/* Pipeline diagnostics — click to expand */}
|
||||||
{pipeline ? (
|
{pipeline ? (
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -58,6 +58,17 @@ export interface TopicDetail {
|
|||||||
* a spinner + "Pipeline is running" pill when > 0 and only offers the
|
* a spinner + "Pipeline is running" pill when > 0 and only offers the
|
||||||
* manual "Submit for review" escape hatch when it's 0. */
|
* manual "Submit for review" escape hatch when it's 0. */
|
||||||
runs_in_flight: number;
|
runs_in_flight: number;
|
||||||
|
/** When present, the topic is owned by a scheduled research loop
|
||||||
|
* (D1 fold). Canvas hides the classic Start/Submit buttons and
|
||||||
|
* shows a "Managed by scheduled loop" strip instead. */
|
||||||
|
managed_by_loop?: {
|
||||||
|
loop_id: string;
|
||||||
|
title: string;
|
||||||
|
enabled: boolean;
|
||||||
|
next_fire_at: string | null;
|
||||||
|
last_run_id: string | null;
|
||||||
|
schedule_summary: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PublishApproval {
|
export interface PublishApproval {
|
||||||
|
|||||||
Reference in New Issue
Block a user