Add routing context to EdgeSelected event: reason, status, hints

Emit reason (condition/preferred_label/suggested_next/unconditional/
jump/fallback), stage_status, preferred_label, suggested_next_ids,
and is_jump so logs explain why an edge was chosen.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-03-14 16:47:28 -04:00
parent 1806aa86d6
commit 4e40f5ce3d
4 changed files with 106 additions and 33 deletions

View file

@ -345,17 +345,19 @@ pub fn format_event_pretty(line: &str, styles: &fabro_util::terminal::Styles) ->
"EdgeSelected" => {
let to = str_field(&envelope, "to_node_id").unwrap_or("?");
let reason = str_field(&envelope, "reason").unwrap_or("?");
let condition = str_field(&envelope, "condition");
let suffix = match condition {
let detail = match condition {
Some(c) => format!(" [{c}]"),
None => String::new(),
};
Some(format!(
"{} {} {}{}",
"{} {} {} {}{}",
styles.dim.apply_to(&ts),
styles.dim.apply_to("\u{2192}"),
to,
styles.dim.apply_to(&suffix),
styles.dim.apply_to(reason),
styles.dim.apply_to(&detail),
))
}

View file

@ -410,6 +410,7 @@ impl ProgressUI {
to_node,
label,
condition,
..
} if self.verbose => {
let detail = if let Some(c) = condition {
format!(" [{c}]")

View file

@ -388,12 +388,18 @@ fn best_by_weight_then_lexical<'a>(edges: &[&'a Edge]) -> Option<&'a Edge> {
/// Select the next edge from a node's outgoing edges (spec Section 3.3).
#[must_use]
/// Result of edge selection: the chosen edge and the reason it was selected.
pub struct EdgeSelection<'a> {
pub edge: &'a Edge,
pub reason: &'static str,
}
pub fn select_edge<'a>(
node_id: &str,
outcome: &Outcome,
context: &Context,
graph: &'a Graph,
) -> Option<&'a Edge> {
) -> Option<EdgeSelection<'a>> {
let edges = graph.outgoing_edges(node_id);
if edges.is_empty() {
return None;
@ -409,7 +415,10 @@ pub fn select_edge<'a>(
.copied()
.collect();
if !condition_matched.is_empty() {
return best_by_weight_then_lexical(&condition_matched);
return best_by_weight_then_lexical(&condition_matched).map(|edge| EdgeSelection {
edge,
reason: "condition",
});
}
// Step 2: Preferred label match
@ -418,7 +427,10 @@ pub fn select_edge<'a>(
for edge in &edges {
if let Some(label) = edge.label() {
if normalize_label(label) == normalized_pref {
return Some(edge);
return Some(EdgeSelection {
edge,
reason: "preferred_label",
});
}
}
}
@ -428,7 +440,10 @@ pub fn select_edge<'a>(
for suggested_id in &outcome.suggested_next_ids {
for edge in &edges {
if edge.to == *suggested_id {
return Some(edge);
return Some(EdgeSelection {
edge,
reason: "suggested_next",
});
}
}
}
@ -440,11 +455,17 @@ pub fn select_edge<'a>(
.copied()
.collect();
if !unconditional.is_empty() {
return best_by_weight_then_lexical(&unconditional);
return best_by_weight_then_lexical(&unconditional).map(|edge| EdgeSelection {
edge,
reason: "unconditional",
});
}
// Fallback: any edge
best_by_weight_then_lexical(&edges)
best_by_weight_then_lexical(&edges).map(|edge| EdgeSelection {
edge,
reason: "fallback",
})
}
// --- Goal gate enforcement ---
@ -1548,10 +1569,10 @@ impl WorkflowRunEngine {
previous_node_id = Some(node.id.clone());
stage_index += 1;
// Select next edge and continue
let edge = select_edge(&node.id, &Outcome::skipped(), &context, graph);
if let Some(e) = edge {
current_node_id = e.to.clone();
incoming_edge = Some(e);
let selection = select_edge(&node.id, &Outcome::skipped(), &context, graph);
if let Some(sel) = selection {
current_node_id = sel.edge.to.clone();
incoming_edge = Some(sel.edge);
} else {
break;
}
@ -1750,25 +1771,36 @@ impl WorkflowRunEngine {
// Step 5: Select next edge (done before checkpoint so we can store next_node_id)
// If the handler specified a direct jump (e.g., parallel -> fan-in),
// bypass edge selection entirely.
let stage_status = outcome.status.to_string();
let (next_edge, jump_target) = if let Some(ref target) = outcome.jump_to_node {
self.services.emitter.emit(&WorkflowRunEvent::EdgeSelected {
from_node: node.id.clone(),
to_node: target.clone(),
label: None,
condition: None,
reason: "jump".to_string(),
preferred_label: outcome.preferred_label.clone(),
suggested_next_ids: outcome.suggested_next_ids.clone(),
stage_status,
is_jump: true,
});
(None, Some(target.clone()))
} else {
let edge = select_edge(&node.id, &outcome, &context, graph);
if let Some(e) = &edge {
let selection = select_edge(&node.id, &outcome, &context, graph);
if let Some(sel) = &selection {
self.services.emitter.emit(&WorkflowRunEvent::EdgeSelected {
from_node: node.id.clone(),
to_node: e.to.clone(),
label: e.label().map(String::from),
condition: e.condition().map(String::from),
to_node: sel.edge.to.clone(),
label: sel.edge.label().map(String::from),
condition: sel.edge.condition().map(String::from),
reason: sel.reason.to_string(),
preferred_label: outcome.preferred_label.clone(),
suggested_next_ids: outcome.suggested_next_ids.clone(),
stage_status,
is_jump: false,
});
}
(edge, None)
(selection.map(|s| s.edge), None)
};
// EdgeSelected hook (blocking — can override routing)
@ -2498,8 +2530,9 @@ mod tests {
let g = make_graph_with_edges(vec![Edge::new("a", "b")]);
let outcome = Outcome::success();
let context = Context::new();
let edge = select_edge("a", &outcome, &context, &g).unwrap();
assert_eq!(edge.to, "b");
let sel = select_edge("a", &outcome, &context, &g).unwrap();
assert_eq!(sel.edge.to, "b");
assert_eq!(sel.reason, "unconditional");
}
#[test]
@ -2517,8 +2550,9 @@ mod tests {
let g = make_graph_with_edges(vec![e1, e2]);
let outcome = Outcome::success();
let context = Context::new();
let edge = select_edge("a", &outcome, &context, &g).unwrap();
assert_eq!(edge.to, "success_path");
let sel = select_edge("a", &outcome, &context, &g).unwrap();
assert_eq!(sel.edge.to, "success_path");
assert_eq!(sel.reason, "condition");
}
#[test]
@ -2537,8 +2571,9 @@ mod tests {
let mut outcome = Outcome::success();
outcome.preferred_label = Some("Fix".to_string());
let context = Context::new();
let edge = select_edge("a", &outcome, &context, &g).unwrap();
assert_eq!(edge.to, "fix");
let sel = select_edge("a", &outcome, &context, &g).unwrap();
assert_eq!(sel.edge.to, "fix");
assert_eq!(sel.reason, "preferred_label");
}
#[test]
@ -2549,8 +2584,9 @@ mod tests {
let mut outcome = Outcome::success();
outcome.suggested_next_ids = vec!["path2".to_string()];
let context = Context::new();
let edge = select_edge("a", &outcome, &context, &g).unwrap();
assert_eq!(edge.to, "path2");
let sel = select_edge("a", &outcome, &context, &g).unwrap();
assert_eq!(sel.edge.to, "path2");
assert_eq!(sel.reason, "suggested_next");
}
#[test]
@ -2563,8 +2599,9 @@ mod tests {
let g = make_graph_with_edges(vec![e1, e2]);
let outcome = Outcome::success();
let context = Context::new();
let edge = select_edge("a", &outcome, &context, &g).unwrap();
assert_eq!(edge.to, "high");
let sel = select_edge("a", &outcome, &context, &g).unwrap();
assert_eq!(sel.edge.to, "high");
assert_eq!(sel.reason, "unconditional");
}
#[test]
@ -2574,8 +2611,9 @@ mod tests {
let g = make_graph_with_edges(vec![e1, e2]);
let outcome = Outcome::success();
let context = Context::new();
let edge = select_edge("a", &outcome, &context, &g).unwrap();
assert_eq!(edge.to, "alpha");
let sel = select_edge("a", &outcome, &context, &g).unwrap();
assert_eq!(sel.edge.to, "alpha");
assert_eq!(sel.reason, "unconditional");
}
#[test]
@ -2589,8 +2627,9 @@ mod tests {
let g = make_graph_with_edges(vec![e_cond, e_uncond]);
let outcome = Outcome::success();
let context = Context::new();
let edge = select_edge("a", &outcome, &context, &g).unwrap();
assert_eq!(edge.to, "cond_path");
let sel = select_edge("a", &outcome, &context, &g).unwrap();
assert_eq!(sel.edge.to, "cond_path");
assert_eq!(sel.reason, "condition");
}
// --- check_goal_gates tests ---

View file

@ -126,6 +126,18 @@ pub enum WorkflowRunEvent {
to_node: String,
label: Option<String>,
condition: Option<String>,
/// Which selection step chose this edge (e.g. "condition", "preferred_label", "jump").
reason: String,
/// The stage's preferred label hint, if any.
#[serde(default, skip_serializing_if = "Option::is_none")]
preferred_label: Option<String>,
/// The stage's suggested next node IDs, if any.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
suggested_next_ids: Vec<String>,
/// The stage outcome status that influenced routing.
stage_status: String,
/// Whether this was a direct jump (bypassing normal edge selection).
is_jump: bool,
},
LoopRestart {
from_node: String,
@ -434,12 +446,14 @@ impl WorkflowRunEvent {
from_node,
to_node,
label,
reason,
..
} => {
debug!(
from_node,
to_node,
label = label.as_deref().unwrap_or(""),
reason,
"Edge selected"
);
}
@ -1297,6 +1311,11 @@ mod tests {
to_node: "code".to_string(),
label: Some("success".to_string()),
condition: Some("outcome == 'success'".to_string()),
reason: "condition".to_string(),
preferred_label: None,
suggested_next_ids: Vec::new(),
stage_status: "success".to_string(),
is_jump: false,
};
let json = serde_json::to_string(&event).unwrap();
assert!(json.contains("EdgeSelected"));
@ -1304,6 +1323,8 @@ mod tests {
assert!(json.contains("\"to_node\":\"code\""));
assert!(json.contains("\"label\":\"success\""));
assert!(json.contains("\"condition\":\"outcome == 'success'\""));
assert!(json.contains("\"reason\":\"condition\""));
assert!(json.contains("\"stage_status\":\"success\""));
let deserialized: WorkflowRunEvent = serde_json::from_str(&json).unwrap();
assert!(
@ -1316,6 +1337,11 @@ mod tests {
to_node: "b".to_string(),
label: None,
condition: None,
reason: "unconditional".to_string(),
preferred_label: None,
suggested_next_ids: Vec::new(),
stage_status: "success".to_string(),
is_jump: false,
};
let json_none = serde_json::to_string(&event_none).unwrap();
assert!(json_none.contains("\"label\":null"));
@ -1723,6 +1749,11 @@ mod tests {
to_node: "code".to_string(),
label: Some("success".to_string()),
condition: None,
reason: "preferred_label".to_string(),
preferred_label: Some("success".to_string()),
suggested_next_ids: Vec::new(),
stage_status: "success".to_string(),
is_jump: false,
};
let (name, fields) = flatten_event(&event);
assert_eq!(name, "EdgeSelected");