diff --git a/crates/forge-attractor/src/handlers/auto_gate.rs b/crates/forge-attractor/src/handlers/auto_gate.rs new file mode 100644 index 0000000..fce2eca --- /dev/null +++ b/crates/forge-attractor/src/handlers/auto_gate.rs @@ -0,0 +1,290 @@ +use crate::{ + AttractorError, Graph, Node, NodeOutcome, NodeStatus, RuntimeContext, handlers::NodeHandler, +}; +use async_trait::async_trait; +use serde_json::Value; + +/// Handler for automatic gate nodes (hexagon shape with `auto_policy` attribute). +/// +/// Supports: +/// - `max_iterations` — hard cap on how many times the gate's loop can cycle. +/// When exceeded, the gate forces the "pass" edge. +/// - `auto_policy="findings_empty"` — routes to the "pass" edge when no findings +/// are detected in the runtime context, otherwise routes to the "findings" edge. +/// +/// Iteration count is tracked via a context variable `gate..iterations`. +/// +/// Edge detection uses the `[P]` / `[F]` accelerator-key convention from the DOT +/// edge labels (e.g. `[P] Pass`, `[F] Findings`). +#[derive(Debug, Default)] +pub struct AutoGateHandler; + +#[async_trait] +impl NodeHandler for AutoGateHandler { + async fn execute( + &self, + node: &Node, + context: &RuntimeContext, + graph: &Graph, + ) -> Result { + let max_iterations = node + .attrs + .get("max_iterations") + .and_then(|v| v.as_i64()) + .unwrap_or(5) as usize; + + let iteration_key = format!("gate.{}.iterations", node.id); + let current = context + .get(&iteration_key) + .and_then(|v| v.as_u64()) + .unwrap_or(0) as usize; + let next = current + 1; + + let edges: Vec<_> = graph.outgoing_edges(&node.id).collect(); + let pass_edge = edges.iter().find(|e| { + let label = e.attrs.get_str("label").unwrap_or(""); + label_is_pass(label) + }); + let findings_edge = edges.iter().find(|e| { + let label = e.attrs.get_str("label").unwrap_or(""); + label_is_findings(label) + }); + + let force_pass = next > max_iterations; + let policy_pass = if !force_pass { + evaluate_policy(node, context) + } else { + false // doesn't matter, force_pass overrides + }; + + let should_pass = force_pass || policy_pass; + + let mut updates = RuntimeContext::new(); + updates.insert(iteration_key, Value::Number(next.into())); + + if should_pass { + let reason = if force_pass { + format!( + "auto gate: max_iterations reached ({}/{}), forcing pass", + next, max_iterations + ) + } else { + format!( + "auto gate: policy satisfied (iteration {}/{})", + next, max_iterations + ) + }; + + if let Some(edge) = pass_edge { + return Ok(NodeOutcome { + status: NodeStatus::Success, + preferred_label: Some( + edge.attrs.get_str("label").unwrap_or("").to_string(), + ), + suggested_next_ids: vec![edge.to.clone()], + context_updates: updates, + notes: Some(reason), + ..Default::default() + }); + } + // No pass edge found — fall through to first edge + return Ok(NodeOutcome { + status: NodeStatus::Success, + context_updates: updates, + notes: Some(format!("{reason} (no pass edge found)")), + ..Default::default() + }); + } + + // Policy says loop: take the findings edge + let reason = format!( + "auto gate: findings detected (iteration {}/{})", + next, max_iterations + ); + if let Some(edge) = findings_edge { + Ok(NodeOutcome { + status: NodeStatus::Success, + preferred_label: Some( + edge.attrs.get_str("label").unwrap_or("").to_string(), + ), + suggested_next_ids: vec![edge.to.clone()], + context_updates: updates, + notes: Some(reason), + ..Default::default() + }) + } else { + // No findings edge — pass by default + Ok(NodeOutcome { + status: NodeStatus::Success, + context_updates: updates, + notes: Some("auto gate: no findings edge, defaulting to pass".to_string()), + ..Default::default() + }) + } + } +} + +/// Evaluate the `auto_policy` attribute on the node. +/// Returns `true` if the gate should pass (route to the pass edge). +fn evaluate_policy(node: &Node, context: &RuntimeContext) -> bool { + let policy = match node.attrs.get_str("auto_policy") { + Some(p) => p, + None => return false, // no policy → default to loop + }; + + match policy { + "findings_empty" => { + // Check for a context variable that signals findings. + // Convention: the immediately preceding stage can set + // `.findings_count` or `findings_count` to 0. + let findings_count = context + .get("findings_count") + .and_then(|v| v.as_u64()); + match findings_count { + Some(0) => true, // explicitly zero findings → pass + Some(_) => false, // non-zero findings → loop + None => false, // no signal → assume findings exist, loop + } + } + "always_pass" => true, + "always_loop" => false, + _ => false, // unknown policy → default to loop + } +} + +fn label_is_pass(label: &str) -> bool { + let lower = label.to_ascii_lowercase(); + lower.contains("[p]") || lower.contains("pass") +} + +fn label_is_findings(label: &str) -> bool { + let lower = label.to_ascii_lowercase(); + lower.contains("[f]") || lower.contains("findings") || lower.contains("finding") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::parse_dot; + + #[tokio::test(flavor = "current_thread")] + async fn auto_gate_first_iteration_with_findings_expected_findings_edge() { + let graph = parse_dot( + r#" + digraph G { + gate [shape=hexagon, auto_policy="findings_empty", max_iterations=5] + fix + pass + gate -> fix [label="[F] Findings"] + gate -> pass [label="[P] Pass"] + } + "#, + ) + .expect("graph should parse"); + let node = graph.nodes.get("gate").expect("gate should exist"); + let context = RuntimeContext::new(); + + let outcome = AutoGateHandler + .execute(node, &context, &graph) + .await + .expect("execution should succeed"); + + assert_eq!(outcome.status, NodeStatus::Success); + assert_eq!(outcome.suggested_next_ids, vec!["fix".to_string()]); + assert!(outcome.notes.unwrap().contains("findings detected")); + } + + #[tokio::test(flavor = "current_thread")] + async fn auto_gate_max_iterations_exceeded_expected_pass_edge() { + let graph = parse_dot( + r#" + digraph G { + gate [shape=hexagon, auto_policy="findings_empty", max_iterations=3] + fix + pass + gate -> fix [label="[F] Findings"] + gate -> pass [label="[P] Pass"] + } + "#, + ) + .expect("graph should parse"); + let node = graph.nodes.get("gate").expect("gate should exist"); + let mut context = RuntimeContext::new(); + // Simulate 3 prior iterations + context.insert( + "gate.gate.iterations".to_string(), + Value::Number(3.into()), + ); + + let outcome = AutoGateHandler + .execute(node, &context, &graph) + .await + .expect("execution should succeed"); + + assert_eq!(outcome.status, NodeStatus::Success); + assert_eq!(outcome.suggested_next_ids, vec!["pass".to_string()]); + assert!(outcome.notes.unwrap().contains("max_iterations reached")); + } + + #[tokio::test(flavor = "current_thread")] + async fn auto_gate_findings_empty_with_zero_count_expected_pass() { + let graph = parse_dot( + r#" + digraph G { + gate [shape=hexagon, auto_policy="findings_empty", max_iterations=5] + fix + pass + gate -> fix [label="[F] Findings"] + gate -> pass [label="[P] Pass"] + } + "#, + ) + .expect("graph should parse"); + let node = graph.nodes.get("gate").expect("gate should exist"); + let mut context = RuntimeContext::new(); + context.insert("findings_count".to_string(), Value::Number(0.into())); + + let outcome = AutoGateHandler + .execute(node, &context, &graph) + .await + .expect("execution should succeed"); + + assert_eq!(outcome.status, NodeStatus::Success); + assert_eq!(outcome.suggested_next_ids, vec!["pass".to_string()]); + assert!(outcome.notes.unwrap().contains("policy satisfied")); + } + + #[tokio::test(flavor = "current_thread")] + async fn auto_gate_increments_iteration_count() { + let graph = parse_dot( + r#" + digraph G { + gate [shape=hexagon, auto_policy="findings_empty", max_iterations=5] + fix + pass + gate -> fix [label="[F] Findings"] + gate -> pass [label="[P] Pass"] + } + "#, + ) + .expect("graph should parse"); + let node = graph.nodes.get("gate").expect("gate should exist"); + let mut context = RuntimeContext::new(); + context.insert( + "gate.gate.iterations".to_string(), + Value::Number(1.into()), + ); + + let outcome = AutoGateHandler + .execute(node, &context, &graph) + .await + .expect("execution should succeed"); + + let updated = outcome + .context_updates + .get("gate.gate.iterations") + .and_then(|v| v.as_u64()) + .expect("iteration count should exist"); + assert_eq!(updated, 2); + } +} diff --git a/crates/forge-attractor/src/handlers/mod.rs b/crates/forge-attractor/src/handlers/mod.rs index b0f44f5..b308509 100644 --- a/crates/forge-attractor/src/handlers/mod.rs +++ b/crates/forge-attractor/src/handlers/mod.rs @@ -2,6 +2,7 @@ use crate::{AttractorError, Graph, Node, NodeOutcome, RuntimeContext}; use async_trait::async_trait; use std::sync::Arc; +pub mod auto_gate; pub mod codergen; pub mod conditional; pub mod exit; @@ -55,6 +56,7 @@ pub fn core_registry_with_codergen_backend( Arc::new(codergen::CodergenHandler::new(backend)), ); registry.register_type("conditional", Arc::new(conditional::ConditionalHandler)); + registry.register_type("auto.gate", Arc::new(auto_gate::AutoGateHandler)); registry.register_type( "wait.human", Arc::new(wait_human::WaitHumanHandler::new(Arc::new( diff --git a/crates/forge-attractor/src/handlers/registry.rs b/crates/forge-attractor/src/handlers/registry.rs index 3b60bf1..6fe4f35 100644 --- a/crates/forge-attractor/src/handlers/registry.rs +++ b/crates/forge-attractor/src/handlers/registry.rs @@ -50,6 +50,12 @@ impl HandlerRegistry { } let shape = node.attrs.get_str("shape").unwrap_or("box"); + + // Hexagons with auto_policy use the auto.gate handler instead of wait.human + if shape == "hexagon" && node.attrs.get_str("auto_policy").is_some() { + return "auto.gate".to_string(); + } + self.shape_to_type .get(shape) .cloned() @@ -161,6 +167,20 @@ mod tests { assert_eq!(registry.resolve_handler_type(&node), "wait.human"); } + #[test] + fn resolve_handler_type_hexagon_with_auto_policy_expected_auto_gate() { + let registry = HandlerRegistry::new(); + let node = node_with_attrs("shape=hexagon, auto_policy=\"findings_empty\""); + assert_eq!(registry.resolve_handler_type(&node), "auto.gate"); + } + + #[test] + fn resolve_handler_type_hexagon_without_auto_policy_expected_wait_human() { + let registry = HandlerRegistry::new(); + let node = node_with_attrs("shape=hexagon"); + assert_eq!(registry.resolve_handler_type(&node), "wait.human"); + } + #[test] fn resolve_handler_type_unknown_shape_expected_default_handler_type() { let registry = HandlerRegistry::new();