research: errored-state card + one-click rerun (no wizard re-entry)
ci / gates (push) Successful in 22s
ci / frontend (push) Successful in 27s
ci / rust (push) Failing after 58s
ci / e2e (push) Skipped
ci / publish (push) Skipped

When a topic ends up parked in 'processing' with all runs failed and
nothing in flight, the sidebar card was still spinning as if
progress were happening. Now:

Backend
- topology_runs::run_counts_by_research_topic — batch query that
  returns (in_flight, failed-since-last-success) per topic. Used by
  the list endpoint; dynamic sqlx::query() so no prepare needed.
- TopicListItem DTO gains runs_in_flight + runs_failed.
- start_topic status guard relaxed: allow (standby) OR (processing
  AND runs_in_flight == 0). Blocks accidental double-fires on a
  live pipeline; permits rerun on a failed one. Same request body,
  same behavior once accepted, so the frontend just POSTs
  /research/:id/start on the RotateCw click.

Frontend
- ResearchList detects errored: status===processing && !in_flight
  && failed>0. Swaps the MiniSpinner for a red AlertTriangle and
  changes the status text to 'error · N failed'.
- New RotateCw icon button next to the delete Trash — same button
  cluster, one click, no wizard re-entry required. Disables while
  a request is in flight; error surfaces in the sidebar's shared
  error banner.
This commit is contained in:
Omar Sobh
2026-07-15 16:27:43 -07:00
parent 43f7880327
commit f70f6c679e
4 changed files with 150 additions and 14 deletions
+30 -2
View File
@@ -406,6 +406,13 @@ pub struct TopicListItem {
pub outcome_kind: String, pub outcome_kind: String,
pub status: String, pub status: String,
pub updated_at: String, pub updated_at: String,
/// queued + running topology_runs bound to this topic.
pub runs_in_flight: i64,
/// failed runs since the last successful run (or all-time if none).
/// > 0 with `runs_in_flight == 0` and `status == "processing"` is
/// the errored-but-not-terminal state — frontend swaps the spinner
/// for an error icon and offers a rerun.
pub runs_failed: i64,
} }
pub async fn list_topics( pub async fn list_topics(
@@ -413,9 +420,19 @@ pub async fn list_topics(
Authed(user): Authed, Authed(user): Authed,
) -> Result<Json<Vec<TopicListItem>>, ApiError> { ) -> Result<Json<Vec<TopicListItem>>, ApiError> {
let rows = cm_db::repo::research_topics::list(&state.pool, user.workspace_id.as_uuid()).await?; let rows = cm_db::repo::research_topics::list(&state.pool, user.workspace_id.as_uuid()).await?;
let ids: Vec<Uuid> = rows.iter().map(|t| t.id).collect();
let counts = cm_db::repo::topology_runs::run_counts_by_research_topic(&state.pool, &ids)
.await
.unwrap_or_default();
let mut count_by: std::collections::HashMap<Uuid, (i64, i64)> = counts
.into_iter()
.map(|(id, in_flight, failed)| (id, (in_flight, failed)))
.collect();
Ok(Json( Ok(Json(
rows.into_iter() rows.into_iter()
.map(|t| TopicListItem { .map(|t| {
let (runs_in_flight, runs_failed) = count_by.remove(&t.id).unwrap_or((0, 0));
TopicListItem {
id: t.id, id: t.id,
title: t.title, title: t.title,
outcome_kind: t.outcome_kind, outcome_kind: t.outcome_kind,
@@ -424,6 +441,9 @@ pub async fn list_topics(
.updated_at .updated_at
.format(&time::format_description::well_known::Rfc3339) .format(&time::format_description::well_known::Rfc3339)
.unwrap_or_default(), .unwrap_or_default(),
runs_in_flight,
runs_failed,
}
}) })
.collect(), .collect(),
)) ))
@@ -657,7 +677,15 @@ pub async fn start_topic(
let topic = cm_db::repo::research_topics::get(&state.pool, id, user.workspace_id.as_uuid()) let topic = cm_db::repo::research_topics::get(&state.pool, id, user.workspace_id.as_uuid())
.await? .await?
.ok_or(ApiError::NotFound)?; .ok_or(ApiError::NotFound)?;
if topic.status != "standby" { // Allow the fresh-start path (standby) AND the rerun path (topic
// parked in `processing` after all runs failed / no runs remain in
// flight). Blocks accidental double-fires on a live pipeline
// (in_flight > 0) and terminal states (reviewing / publishing /
// published).
let in_flight =
cm_db::repo::topology_runs::active_runs_for_research_topic(&state.pool, id).await?;
let can_start = topic.status == "standby" || (topic.status == "processing" && in_flight == 0);
if !can_start {
return Err(ApiError::Conflict); return Err(ApiError::Conflict);
} }
// D1 fold — refuse to double-fire when a scheduled research loop // D1 fold — refuse to double-fire when a scheduled research loop
+52
View File
@@ -171,6 +171,58 @@ pub async fn active_runs_for_research_topic(
/// the actual run ids (queued + running) so the UI can subscribe to /// the actual run ids (queued + running) so the UI can subscribe to
/// their SSE event streams. Ordered newest first — the freshest run is /// their SSE event streams. Ordered newest first — the freshest run is
/// the one the user just kicked off. /// the one the user just kicked off.
/// Batch run-count feeder for the research topic list. Returns a
/// (topic_id, in_flight, failed) tuple per topic in `topic_ids`,
/// omitting topics with zero runs. Used to render the errored-state
/// icon + "rerun" affordance on cards in the left sidebar.
///
/// `failed` counts runs that terminated in `failed` since the topic's
/// most recent successful run (or all-time if none have succeeded).
/// That way an old failure on a topic that later succeeded doesn't
/// keep the card flagged as broken.
pub async fn run_counts_by_research_topic(
pool: &PgPool,
topic_ids: &[Uuid],
) -> Result<Vec<(Uuid, i64, i64)>, DbError> {
use sqlx::Row;
if topic_ids.is_empty() {
return Ok(Vec::new());
}
let rows: Vec<sqlx::postgres::PgRow> = sqlx::query(
"WITH last_success AS (
SELECT research_topic_id, max(created_at) AS ts
FROM topology_runs
WHERE research_topic_id = ANY($1)
AND status = 'completed'
GROUP BY research_topic_id
)
SELECT r.research_topic_id AS topic_id,
count(*) FILTER (WHERE r.status IN ('queued','running')) AS in_flight,
count(*) FILTER (
WHERE r.status = 'failed'
AND r.created_at > coalesce(ls.ts, 'epoch'::timestamptz)
) AS failed
FROM topology_runs r
LEFT JOIN last_success ls
ON ls.research_topic_id = r.research_topic_id
WHERE r.research_topic_id = ANY($1)
GROUP BY r.research_topic_id",
)
.bind(topic_ids)
.fetch_all(pool)
.await?;
Ok(rows
.into_iter()
.map(|r| {
(
r.get::<Uuid, _>("topic_id"),
r.get::<i64, _>("in_flight"),
r.get::<i64, _>("failed"),
)
})
.collect())
}
pub async fn active_run_ids_for_research_topic( pub async fn active_run_ids_for_research_topic(
pool: &PgPool, pool: &PgPool,
research_topic_id: Uuid, research_topic_id: Uuid,
@@ -6,13 +6,14 @@
// outcomes; topology_runs.research_topic_id is SET NULL). // outcomes; topology_runs.research_topic_id is SET NULL).
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Download, Plus, Trash2 } from "lucide-react"; import { AlertTriangle, Download, Plus, RotateCw, Trash2 } from "lucide-react";
import type { Agent } from "@/lib/api/schemas"; import type { Agent } from "@/lib/api/schemas";
import { import {
deleteTopic, deleteTopic,
listPendingApprovals, listPendingApprovals,
listTopics, listTopics,
startTopic,
type PublishApproval, type PublishApproval,
type TopicListItem, type TopicListItem,
type TopicStatus, type TopicStatus,
@@ -119,6 +120,20 @@ export function ResearchList({
} }
} }
async function doRerun(id: string) {
if (busyId) return;
setBusyId(id);
setError(null);
try {
await startTopic(id);
setLocalBump((n) => n + 1);
} catch (e) {
setError(e instanceof Error ? e.message : "rerun failed");
} finally {
setBusyId(null);
}
}
return ( return (
<> <>
<div <div
@@ -297,7 +312,14 @@ export function ResearchList({
) : ( ) : (
topics.map((t) => { topics.map((t) => {
const on = t.id === selectedId; const on = t.id === selectedId;
const dot = STATUS_COLOR[t.status]; // Errored-processing: pipeline in `processing` but all runs
// failed and none in flight. Swap spinner for a red error
// icon and expose a rerun affordance.
const errored =
t.status === "processing" &&
t.runs_in_flight === 0 &&
(t.runs_failed ?? 0) > 0;
const dot = errored ? "#ff8a7a" : STATUS_COLOR[t.status];
const confirming = confirmDeleteId === t.id; const confirming = confirmDeleteId === t.id;
return ( return (
<div <div
@@ -346,7 +368,9 @@ export function ResearchList({
color: "#8a8a92", color: "#8a8a92",
}} }}
> >
{t.status === "processing" || t.status === "publishing" ? ( {errored ? (
<AlertTriangle aria-hidden size={11} color={dot} />
) : t.status === "processing" || t.status === "publishing" ? (
<MiniSpinner color={dot} /> <MiniSpinner color={dot} />
) : ( ) : (
<span <span
@@ -358,7 +382,9 @@ export function ResearchList({
}} }}
/> />
)} )}
<span style={{ color: dot }}>{t.status}</span> <span style={{ color: dot }}>
{errored ? `error · ${t.runs_failed} failed` : t.status}
</span>
<span style={{ opacity: 0.5 }}>·</span> <span style={{ opacity: 0.5 }}>·</span>
<span>{t.outcome_kind.replace("_", " ")}</span> <span>{t.outcome_kind.replace("_", " ")}</span>
</div> </div>
@@ -433,6 +459,30 @@ export function ResearchList({
<Download aria-hidden size={12} /> <Download aria-hidden size={12} />
</a> </a>
) : null} ) : null}
{errored ? (
<button
type="button"
title="Rerun the pipeline for this topic"
aria-label="Rerun topic"
onClick={() => doRerun(t.id)}
disabled={busyId === t.id}
style={{
width: 24,
height: 24,
borderRadius: 6,
border: "1px solid rgba(255,255,255,.08)",
background: "transparent",
color: "#5ec8d8",
cursor: busyId === t.id ? "not-allowed" : "pointer",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
opacity: busyId === t.id ? 0.5 : 1,
}}
>
<RotateCw aria-hidden size={12} />
</button>
) : null}
<button <button
type="button" type="button"
title="Delete topic" title="Delete topic"
+6
View File
@@ -15,6 +15,12 @@ export interface TopicListItem {
outcome_kind: OutcomeKind; outcome_kind: OutcomeKind;
status: TopicStatus; status: TopicStatus;
updated_at: string; updated_at: string;
/** Queued + running topology_runs bound to this topic. */
runs_in_flight: number;
/** Failed runs since the last successful run. Combined with
* status === "processing" && runs_in_flight === 0, drives the
* errored-state icon + rerun affordance in ResearchList. */
runs_failed: number;
} }
export interface AgentSlot { export interface AgentSlot {