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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user