research: errored-state card + one-click rerun (no wizard re-entry)
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:
@@ -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,17 +420,30 @@ 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| {
|
||||||
id: t.id,
|
let (runs_in_flight, runs_failed) = count_by.remove(&t.id).unwrap_or((0, 0));
|
||||||
title: t.title,
|
TopicListItem {
|
||||||
outcome_kind: t.outcome_kind,
|
id: t.id,
|
||||||
status: t.status,
|
title: t.title,
|
||||||
updated_at: t
|
outcome_kind: t.outcome_kind,
|
||||||
.updated_at
|
status: t.status,
|
||||||
.format(&time::format_description::well_known::Rfc3339)
|
updated_at: t
|
||||||
.unwrap_or_default(),
|
.updated_at
|
||||||
|
.format(&time::format_description::well_known::Rfc3339)
|
||||||
|
.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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user