research-pipeline-diag: state-aware statuses (waiting != failing)
The diagnostic was calling any 0-outcome + 0-completed-runs state a
FAIL — red dot + 'No outcome produced yet — check the runs stage for
the failure reason'. That fires the moment a wizard-materialized
topic lands, before its very first turn even completes, and stays
red for the whole 2-3 min a legitimate coordinator turn runs. Result:
users see 'FAIL' on every fresh topic and can't tell a real failure
from a normal in-flight state.
Backend fix — introduce a `waiting` status (blue/pulsing in UI):
Runs stage:
0 runs -> skip ('No runs yet — pipeline hasn't fired')
any running/queued -> waiting ('N in flight, M completed')
all failed -> fail (with error text)
some failed -> warn
all completed no fails -> ok
Outcomes stage:
outcome_count > 0 -> ok
0 outcomes + 0 runs -> skip ('No outcome yet (pipeline hasn't fired)')
0 outcomes + any running -> waiting ('Waiting for the current run to finish…')
0 outcomes + any failed -> fail (the actual silent-bug case)
0 outcomes + all done ok -> warn (weird — completed but wrote nothing)
Also suppresses the run stage's `latest_error` detail when the run
status is `waiting` or `skip` — reporting a stale error next to an
actively-running job is what made users think the current run had
failed.
Frontend:
- PipelineStage['status'] union grows a 'waiting' arm.
- Pill color: cyan (#5ec8d8) with a pulsing scale/opacity animation
(new cm-pulse keyframe in motion.css).
- Strip summary line: 'Pipeline in flight — waiting for run to finish…'
when there's any waiting stage and no failures.
- Border tint: cyan border when waiting, coral when failing, neutral
otherwise.
Zero backend semantic changes to the outcome-write path — this is
purely UI truth-telling.
This commit is contained in:
@@ -122,12 +122,35 @@ pub async fn pipeline_state(
|
|||||||
.iter()
|
.iter()
|
||||||
.filter(|r| r.try_get::<String, _>("status").ok().as_deref() == Some("failed"))
|
.filter(|r| r.try_get::<String, _>("status").ok().as_deref() == Some("failed"))
|
||||||
.count();
|
.count();
|
||||||
|
let n_running = run_rows
|
||||||
|
.iter()
|
||||||
|
.filter(|r| {
|
||||||
|
matches!(
|
||||||
|
r.try_get::<String, _>("status").ok().as_deref(),
|
||||||
|
Some("running") | Some("queued")
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.count();
|
||||||
|
let n_completed = run_rows
|
||||||
|
.iter()
|
||||||
|
.filter(|r| r.try_get::<String, _>("status").ok().as_deref() == Some("completed"))
|
||||||
|
.count();
|
||||||
let latest_error = run_rows
|
let latest_error = run_rows
|
||||||
.iter()
|
.iter()
|
||||||
.find_map(|r| r.try_get::<Option<String>, _>("error").ok().flatten())
|
.find_map(|r| r.try_get::<Option<String>, _>("error").ok().flatten())
|
||||||
.filter(|s| !s.is_empty());
|
.filter(|s| !s.is_empty());
|
||||||
|
// Status rules:
|
||||||
|
// - 0 runs → skip (nothing to see yet — natural pre-fire state,
|
||||||
|
// NOT a failure)
|
||||||
|
// - any running → waiting (blue/spinner in UI — legitimate in-flight
|
||||||
|
// state)
|
||||||
|
// - all failed → fail (nothing succeeded)
|
||||||
|
// - some failed → warn (mixed history)
|
||||||
|
// - all completed → ok
|
||||||
let run_status = if n_runs == 0 {
|
let run_status = if n_runs == 0 {
|
||||||
"warn"
|
"skip"
|
||||||
|
} else if n_running > 0 {
|
||||||
|
"waiting"
|
||||||
} else if n_failed == n_runs {
|
} else if n_failed == n_runs {
|
||||||
"fail"
|
"fail"
|
||||||
} else if n_failed > 0 {
|
} else if n_failed > 0 {
|
||||||
@@ -135,29 +158,73 @@ pub async fn pipeline_state(
|
|||||||
} else {
|
} else {
|
||||||
"ok"
|
"ok"
|
||||||
};
|
};
|
||||||
|
let run_label = if n_runs == 0 {
|
||||||
|
"No runs yet — pipeline hasn't fired".to_string()
|
||||||
|
} else if n_running > 0 && n_failed == 0 {
|
||||||
|
format!("{n_running} in flight, {n_completed} completed")
|
||||||
|
} else if n_running > 0 {
|
||||||
|
format!("{n_running} in flight, {n_completed} completed, {n_failed} failed")
|
||||||
|
} else {
|
||||||
|
format!("{n_runs} run(s), {n_failed} failed, {n_completed} completed")
|
||||||
|
};
|
||||||
stages.push(PipelineStage {
|
stages.push(PipelineStage {
|
||||||
key: "runs".into(),
|
key: "runs".into(),
|
||||||
label: format!("{n_runs} run(s), {n_failed} failed"),
|
label: run_label,
|
||||||
|
// Suppress the "failure" detail line while runs are still in flight —
|
||||||
|
// reporting a prior turn's stale error text next to an actively-running
|
||||||
|
// job reads like the current run failed, which is what triggered the
|
||||||
|
// "everything looks broken" impression.
|
||||||
status: run_status,
|
status: run_status,
|
||||||
detail: latest_error,
|
detail: if run_status == "waiting" || run_status == "skip" {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
latest_error
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// 5. outcomes — the artifact rows get_artifact reads.
|
// 5. outcomes — the artifact rows get_artifact reads. Status is
|
||||||
|
// state-aware: an outcome-less topic with an in-flight run is a
|
||||||
|
// NORMAL waiting state, not a failure. Only flag `fail` when all
|
||||||
|
// runs have terminated AND none produced an outcome — the actual
|
||||||
|
// silent-bug case this diagnostic was designed to catch.
|
||||||
let outcome_count: i64 =
|
let outcome_count: i64 =
|
||||||
sqlx::query_scalar("SELECT count(*) FROM research_outcomes WHERE topic_id = $1")
|
sqlx::query_scalar("SELECT count(*) FROM research_outcomes WHERE topic_id = $1")
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.fetch_one(&state.pool)
|
.fetch_one(&state.pool)
|
||||||
.await
|
.await
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
stages.push(PipelineStage {
|
let outcome_status = if outcome_count > 0 {
|
||||||
key: "outcomes".into(),
|
"ok"
|
||||||
label: format!("{outcome_count} outcome(s) written"),
|
} else if n_runs == 0 {
|
||||||
status: if outcome_count > 0 { "ok" } else { "fail" },
|
"skip"
|
||||||
detail: if outcome_count == 0 {
|
} else if n_running > 0 {
|
||||||
Some("No outcome produced yet — check the runs stage for the failure reason.".into())
|
"waiting"
|
||||||
|
} else if n_failed > 0 {
|
||||||
|
"fail"
|
||||||
|
} else {
|
||||||
|
"warn"
|
||||||
|
};
|
||||||
|
let outcome_label = if outcome_count > 0 {
|
||||||
|
format!("{outcome_count} outcome(s) written")
|
||||||
|
} else if n_running > 0 {
|
||||||
|
"Waiting for the current run to finish…".to_string()
|
||||||
|
} else if n_runs == 0 {
|
||||||
|
"No outcome yet (pipeline hasn't fired)".to_string()
|
||||||
|
} else if n_failed > 0 {
|
||||||
|
"No outcome — all runs failed".to_string()
|
||||||
|
} else {
|
||||||
|
"No outcome yet".to_string()
|
||||||
|
};
|
||||||
|
let outcome_detail = if outcome_status == "fail" {
|
||||||
|
Some("No outcome produced — check the runs stage for the failure reason.".into())
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
},
|
};
|
||||||
|
stages.push(PipelineStage {
|
||||||
|
key: "outcomes".into(),
|
||||||
|
label: outcome_label,
|
||||||
|
status: outcome_status,
|
||||||
|
detail: outcome_detail,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 6. approval — pending publish-approval, if any.
|
// 6. approval — pending publish-approval, if any.
|
||||||
|
|||||||
@@ -402,7 +402,13 @@ export function ResearchCanvas({
|
|||||||
width: "100%",
|
width: "100%",
|
||||||
padding: "10px 12px",
|
padding: "10px 12px",
|
||||||
borderRadius: 10,
|
borderRadius: 10,
|
||||||
border: `1px solid ${pipeline.stages.some((s) => s.status === "fail") ? "rgba(255,138,122,.4)" : "rgba(255,255,255,.08)"}`,
|
border: `1px solid ${
|
||||||
|
pipeline.stages.some((s) => s.status === "fail")
|
||||||
|
? "rgba(255,138,122,.4)"
|
||||||
|
: pipeline.stages.some((s) => s.status === "waiting")
|
||||||
|
? "rgba(94,200,216,.35)"
|
||||||
|
: "rgba(255,255,255,.08)"
|
||||||
|
}`,
|
||||||
background: pipeline.stages.some((s) => s.status === "fail")
|
background: pipeline.stages.some((s) => s.status === "fail")
|
||||||
? "rgba(255,138,122,.06)"
|
? "rgba(255,138,122,.06)"
|
||||||
: "#101014",
|
: "#101014",
|
||||||
@@ -426,19 +432,25 @@ export function ResearchCanvas({
|
|||||||
borderRadius: "50%",
|
borderRadius: "50%",
|
||||||
background:
|
background:
|
||||||
s.status === "ok" ? "#5fd08a"
|
s.status === "ok" ? "#5fd08a"
|
||||||
|
: s.status === "waiting" ? "#5ec8d8"
|
||||||
: s.status === "warn" ? "#ffb44a"
|
: s.status === "warn" ? "#ffb44a"
|
||||||
: s.status === "fail" ? "#ff8a7a"
|
: s.status === "fail" ? "#ff8a7a"
|
||||||
: "#4a4a52",
|
: "#4a4a52",
|
||||||
|
animation: s.status === "waiting" ? "cm-pulse 1.4s ease-in-out infinite" : undefined,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</span>
|
</span>
|
||||||
<span style={{ flex: 1, color: "#8a8a92" }}>
|
<span style={{ flex: 1, color: "#8a8a92" }}>
|
||||||
{pipeline.stages.filter((s) => s.status === "fail").length > 0
|
{(() => {
|
||||||
? `${pipeline.stages.filter((s) => s.status === "fail").length} failed stage(s) — click for details`
|
const failed = pipeline.stages.filter((s) => s.status === "fail").length;
|
||||||
: pipeline.stages.filter((s) => s.status === "warn").length > 0
|
const waiting = pipeline.stages.filter((s) => s.status === "waiting").length;
|
||||||
? `${pipeline.stages.filter((s) => s.status === "warn").length} warning(s)`
|
const warn = pipeline.stages.filter((s) => s.status === "warn").length;
|
||||||
: "All stages ok"}
|
if (failed > 0) return `${failed} failed stage(s) — click for details`;
|
||||||
|
if (waiting > 0) return "Pipeline in flight — waiting for run to finish…";
|
||||||
|
if (warn > 0) return `${warn} warning(s)`;
|
||||||
|
return "All stages ok";
|
||||||
|
})()}
|
||||||
</span>
|
</span>
|
||||||
<span style={{ opacity: 0.7 }}>{diagOpen ? "▾" : "▸"}</span>
|
<span style={{ opacity: 0.7 }}>{diagOpen ? "▾" : "▸"}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -165,7 +165,9 @@ export const approvePublish = (id: string) =>
|
|||||||
export interface PipelineStage {
|
export interface PipelineStage {
|
||||||
key: string;
|
key: string;
|
||||||
label: string;
|
label: string;
|
||||||
status: "ok" | "warn" | "fail" | "skip";
|
/** waiting = a run is legitimately in flight; NOT a failure state.
|
||||||
|
* Rendered as a pulsing cyan dot to distinguish from red "fail". */
|
||||||
|
status: "ok" | "warn" | "fail" | "skip" | "waiting";
|
||||||
detail?: string;
|
detail?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,10 @@
|
|||||||
50% { background-position: 100% 50%; }
|
50% { background-position: 100% 50%; }
|
||||||
100% { background-position: 0% 50%; }
|
100% { background-position: 0% 50%; }
|
||||||
}
|
}
|
||||||
|
@keyframes cm-pulse {
|
||||||
|
0%, 100% { opacity: 1; transform: scale(1); }
|
||||||
|
50% { opacity: 0.55; transform: scale(0.85); }
|
||||||
|
}
|
||||||
@keyframes caret-blink {
|
@keyframes caret-blink {
|
||||||
0%, 45% { opacity: 1; }
|
0%, 45% { opacity: 1; }
|
||||||
50%, 95% { opacity: 0; }
|
50%, 95% { opacity: 0; }
|
||||||
|
|||||||
Reference in New Issue
Block a user