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
+38 -10
View File
@@ -406,6 +406,13 @@ pub struct TopicListItem {
pub outcome_kind: String,
pub status: 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(
@@ -413,17 +420,30 @@ pub async fn list_topics(
Authed(user): Authed,
) -> Result<Json<Vec<TopicListItem>>, ApiError> {
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(
rows.into_iter()
.map(|t| TopicListItem {
id: t.id,
title: t.title,
outcome_kind: t.outcome_kind,
status: t.status,
updated_at: t
.updated_at
.format(&time::format_description::well_known::Rfc3339)
.unwrap_or_default(),
.map(|t| {
let (runs_in_flight, runs_failed) = count_by.remove(&t.id).unwrap_or((0, 0));
TopicListItem {
id: t.id,
title: t.title,
outcome_kind: t.outcome_kind,
status: t.status,
updated_at: t
.updated_at
.format(&time::format_description::well_known::Rfc3339)
.unwrap_or_default(),
runs_in_flight,
runs_failed,
}
})
.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())
.await?
.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);
}
// D1 fold — refuse to double-fire when a scheduled research loop