From 1470e190dbce706044361b36b0bb4d6a12b1e110 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Mon, 4 May 2026 18:40:17 -0400 Subject: [PATCH] fix(server): handle projection and visit edge cases in stages handler Replace `unwrap_or_default()` and `expect()` in the run-stages handler with warn-and-degrade paths so a corrupted event log or malformed StageId does not silently empty the response or 500 the request. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/server/handler/billing.rs | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/lib/crates/fabro-server/src/server/handler/billing.rs b/lib/crates/fabro-server/src/server/handler/billing.rs index 36e815e75..d3d56b3a2 100644 --- a/lib/crates/fabro-server/src/server/handler/billing.rs +++ b/lib/crates/fabro-server/src/server/handler/billing.rs @@ -68,7 +68,17 @@ async fn list_run_stages( Err(_) => return ApiError::not_found("Run not found.").into_response(), }; - let projection = RunProjection::apply_events(&events).unwrap_or_default(); + let projection = match RunProjection::apply_events(&events) { + Ok(projection) => projection, + Err(err) => { + tracing::warn!( + run_id = %id, + error = %err, + "Failed to build run projection; returning empty stages list", + ); + RunProjection::default() + } + }; let stage_durations = fabro_workflow::extract_stage_durations_by_stage_id(&events); let lifecycle_states = latest_stage_states(&events); @@ -78,7 +88,14 @@ async fn list_run_stages( let mut stages = Vec::with_capacity(entries.len()); for (stage_id, stage_projection) in entries { let node_id = stage_id.node_id().to_string(); - let visit = NonZeroU32::new(stage_id.visit()).expect("StageId.visit is 1-based"); + let Some(visit) = NonZeroU32::new(stage_id.visit()) else { + tracing::warn!( + run_id = %id, + stage_id = %stage_id, + "Skipping stage with non-positive visit", + ); + continue; + }; // Prefer the latest lifecycle event; fall back to the projection's // stored completion (e.g. for runs recovered from snapshot only). let status = lifecycle_states.get(stage_id).copied().unwrap_or_else(|| {