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
@@ -57,22 +57,25 @@ function nextAction(
status: TopicStatus,
hasPendingPublish: boolean,
runsInFlight: number,
managedByLoop: boolean,
): {
label: string;
run: (id: string) => Promise<unknown>;
} | 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) {
case "standby":
return { label: "Start research", run: startTopic };
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;
return { label: "Submit for review (manual)", run: submitReview };
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;
return { label: "Request publish", run: requestPublish };
default:
@@ -225,6 +228,7 @@ export function ResearchCanvas({
topic.status,
topic.has_pending_publish_request,
topic.runs_in_flight,
!!topic.managed_by_loop,
);
const stageCopy = STAGE_COPY[topic.status];
const pipelineRunning =
@@ -346,6 +350,45 @@ export function ResearchCanvas({
</h1>
</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 ? (
<div>