fix: include duration_ms in stage.failed events for sidebar display

StageFailedProps lacked duration_ms, so extract_stage_durations_from_events
only found durations from stage.completed events. Failed stages showed "0s"
in the sidebar despite the command.completed event having the real duration.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-04-15 10:42:58 -04:00
parent 0c81f61af0
commit de78094d7e
No known key found for this signature in database
5 changed files with 41 additions and 30 deletions

View file

@ -50,10 +50,12 @@ pub struct StageCompletedProps {
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct StageFailedProps {
pub index: usize,
pub index: usize,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub failure: Option<FailureDetail>,
pub will_retry: bool,
pub failure: Option<FailureDetail>,
pub will_retry: bool,
#[serde(default)]
pub duration_ms: u64,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]

View file

@ -1700,11 +1700,12 @@ mod tests {
// 3. Outcome → StageFailed event
let failure = outcome.failure.clone().unwrap();
let event = Event::StageFailed {
node_id: "code".into(),
name: "code".into(),
index: 0,
failure: failure.clone(),
will_retry: false,
node_id: "code".into(),
name: "code".into(),
index: 0,
failure: failure.clone(),
will_retry: false,
duration_ms: 0,
};
// 4. Verify classification survived all the way through

View file

@ -181,11 +181,12 @@ pub enum Event {
max_attempts: usize,
},
StageFailed {
node_id: String,
name: String,
index: usize,
failure: FailureDetail,
will_retry: bool,
node_id: String,
name: String,
index: usize,
failure: FailureDetail,
will_retry: bool,
duration_ms: u64,
},
StageRetrying {
node_id: String,
@ -1652,11 +1653,13 @@ fn event_body_from_event(event: &Event) -> EventBody {
index,
failure,
will_retry,
duration_ms,
..
} => EventBody::StageFailed(fabro_types::StageFailedProps {
index: *index,
failure: Some(failure.clone()),
will_retry: *will_retry,
index: *index,
failure: Some(failure.clone()),
will_retry: *will_retry,
duration_ms: *duration_ms,
}),
Event::StageRetrying {
index,
@ -2994,14 +2997,15 @@ mod tests {
#[test]
fn run_event_stage_failure_keeps_failure_detail() {
let stored = to_run_event(&fixtures::RUN_3, &Event::StageFailed {
node_id: "code".to_string(),
name: "Code".to_string(),
index: 1,
failure: FailureDetail::new(
node_id: "code".to_string(),
name: "Code".to_string(),
index: 1,
failure: FailureDetail::new(
"lint failed",
crate::outcome::FailureCategory::Deterministic,
),
will_retry: true,
will_retry: true,
duration_ms: 5000,
});
assert_eq!(stored.event_name(), "stage.failed");

View file

@ -92,7 +92,8 @@ pub fn extract_stage_durations_from_events(events: &[EventEnvelope]) -> HashMap<
let mut durations = HashMap::new();
for envelope in events {
let value = envelope.payload.as_value();
if value.get("event").and_then(serde_json::Value::as_str) != Some("stage.completed") {
let event_name = value.get("event").and_then(serde_json::Value::as_str);
if event_name != Some("stage.completed") && event_name != Some("stage.failed") {
continue;
}
let Some(node_id) = value.get("node_id").and_then(serde_json::Value::as_str) else {

View file

@ -209,15 +209,17 @@ impl RunLifecycle<WorkflowGraph> for EventLifecycle {
let stage_index = state.stage_index;
let scope = stage_scope_for(state, &gv.id);
let duration_ms = u64::try_from(ctx.result.duration.as_millis()).unwrap();
self.emitter.emit_scoped(
&Event::StageFailed {
node_id: gv.id.clone(),
name: gv.label().to_string(),
index: stage_index,
failure: outcome.failure.clone().unwrap_or_else(|| {
node_id: gv.id.clone(),
name: gv.label().to_string(),
index: stage_index,
failure: outcome.failure.clone().unwrap_or_else(|| {
FailureDetail::new("handler failed", FailureCategory::TransientInfra)
}),
will_retry: true,
duration_ms,
},
&scope,
);
@ -260,13 +262,14 @@ impl RunLifecycle<WorkflowGraph> for EventLifecycle {
if outcome.status == StageStatus::Fail {
self.emitter.emit_scoped(
&Event::StageFailed {
node_id: gv.id.clone(),
name: gv.label().to_string(),
index: stage_index,
failure: outcome.failure.clone().unwrap_or_else(|| {
node_id: gv.id.clone(),
name: gv.label().to_string(),
index: stage_index,
failure: outcome.failure.clone().unwrap_or_else(|| {
FailureDetail::new("handler failed", FailureCategory::Deterministic)
}),
will_retry: false,
duration_ms,
},
&scope,
);