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
+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
/// their SSE event streams. Ordered newest first — the freshest run is
/// 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(
pool: &PgPool,
research_topic_id: Uuid,