research-canvas: managed-by-loop UI + start_topic guard (fold cleanup)
ci / gates (push) Successful in 18s
ci / frontend (push) Successful in 31s
ci / rust (push) Successful in 2m43s
ci / e2e (push) Has been skipped
ci / publish (push) Successful in 2m57s

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:
Omar Sobh
2026-07-10 17:52:13 -07:00
parent f910771bbb
commit bfcdca0583
6 changed files with 240 additions and 5 deletions
+14
View File
@@ -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,