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:
@@ -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
|
||||
/// (loop_id, workspace_id, task_template, graph) so the caller can
|
||||
/// 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(
|
||||
pool: &PgPool,
|
||||
topic_id: Uuid,
|
||||
|
||||
@@ -153,6 +153,38 @@ pub async fn list(pool: &PgPool, workspace_id: Uuid) -> Result<Vec<ResearchTopic
|
||||
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
|
||||
/// completion hooks, kind='research' loop iteration builders) where
|
||||
/// the caller already has an authoritative workspace binding from the
|
||||
|
||||
Reference in New Issue
Block a user