Skip to main content

harness_loop/
blueprint.rs

1//! Blueprint — deterministic + agent hybrid state machine (Stripe Minions pattern).
2//!
3//! Each node is either:
4//! - **Deterministic** — runs an `async FnMut(&mut World) -> Result<NodeOutput>`
5//! - **Agent** — placeholder; in v0.1 we accept an opaque async closure so users
6//!   can plug in `AgentLoop` themselves (see `examples/`). A first-class
7//!   `Node::Agent { AgentLoop }` is sketched in DESIGN.md §8 and lands once
8//!   we resolve the variance/lifetime story for `&mut AgentLoop` in a `Box`.
9//!
10//! Control flow:
11//! - Edges are followed by `NodeOutput.transition`.
12//! - Failure of a node can branch to a retry/fallback node when `branch_on_failure`
13//!   is set; up to `retry_cap` retries.
14
15use futures::future::BoxFuture;
16use harness_core::{HarnessError, World};
17use serde::{Deserialize, Serialize};
18use std::collections::HashMap;
19
20pub type NodeId = String;
21
22/// What a node returns. `transition` decides what edge to follow.
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct NodeOutput {
25    pub transition: Transition,
26    #[serde(default)]
27    pub data: serde_json::Value,
28}
29
30#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
31#[serde(rename_all = "kebab-case")]
32#[non_exhaustive]
33pub enum Transition {
34    /// Follow the named edge.
35    Edge(String),
36    /// Use the default ("next") edge if any.
37    Next,
38    /// Mark the run complete; ignore any downstream edges.
39    Done,
40    /// Abort the blueprint with this reason.
41    Abort(String),
42}
43
44/// One executable node.
45pub enum Node {
46    /// Deterministic Rust closure. Receives `&mut World`. Cheap; no tokens.
47    Deterministic(BoxedDetermFn),
48    /// User-supplied async closure that knows how to run an agent loop.
49    /// Receives `&mut World`. This is the v0.1 escape hatch.
50    Agent(BoxedAgentFn),
51}
52
53type BoxedDetermFn = Box<
54    dyn for<'a> Fn(&'a mut World) -> BoxFuture<'a, Result<NodeOutput, HarnessError>> + Send + Sync,
55>;
56
57type BoxedAgentFn = Box<
58    dyn for<'a> Fn(&'a mut World) -> BoxFuture<'a, Result<NodeOutput, HarnessError>> + Send + Sync,
59>;
60
61impl Node {
62    /// Wrap a closure that returns a boxed future. The closure body can borrow
63    /// `&mut World` for the future's lifetime.
64    ///
65    /// ```ignore
66    /// Node::deterministic(|w| Box::pin(async move {
67    ///     let out = w.runner.exec("git", &["status"], None).await?;
68    ///     Ok(NodeOutput { transition: Transition::Next, data: ... })
69    /// }))
70    /// ```
71    pub fn deterministic<F>(f: F) -> Self
72    where
73        F: for<'a> Fn(&'a mut World) -> BoxFuture<'a, Result<NodeOutput, HarnessError>>
74            + Send
75            + Sync
76            + 'static,
77    {
78        Node::Deterministic(Box::new(f))
79    }
80
81    pub fn agent<F>(f: F) -> Self
82    where
83        F: for<'a> Fn(&'a mut World) -> BoxFuture<'a, Result<NodeOutput, HarnessError>>
84            + Send
85            + Sync
86            + 'static,
87    {
88        Node::Agent(Box::new(f))
89    }
90}
91
92/// Convenience: avoid the `Box::pin` boilerplate when the closure doesn't
93/// borrow `w`.
94#[macro_export]
95macro_rules! node_async {
96    (|$w:ident| $body:block) => {
97        $crate::blueprint::Node::deterministic(|$w| Box::pin(async move $body))
98    };
99}
100
101/// An edge — either named or default.
102#[derive(Debug, Clone)]
103struct EdgeDef {
104    /// `None` means the default "next" edge.
105    name: Option<String>,
106    target: NodeId,
107}
108
109/// Per-node failure policy.
110#[derive(Debug, Clone, Default)]
111struct FailurePolicy {
112    fallback: Option<NodeId>,
113    retry_cap: u32,
114}
115
116pub struct Blueprint {
117    nodes: HashMap<NodeId, Node>,
118    edges: HashMap<NodeId, Vec<EdgeDef>>,
119    failure: HashMap<NodeId, FailurePolicy>,
120    start: Option<NodeId>,
121}
122
123impl Default for Blueprint {
124    fn default() -> Self {
125        Self::new()
126    }
127}
128
129impl Blueprint {
130    pub fn new() -> Self {
131        Self {
132            nodes: HashMap::new(),
133            edges: HashMap::new(),
134            failure: HashMap::new(),
135            start: None,
136        }
137    }
138
139    /// Add a node. The first node added becomes the start (overridable via `start()`).
140    pub fn add(mut self, id: impl Into<NodeId>, node: Node) -> Self {
141        let id = id.into();
142        if self.start.is_none() {
143            self.start = Some(id.clone());
144        }
145        self.nodes.insert(id, node);
146        self
147    }
148
149    pub fn start(mut self, id: impl Into<NodeId>) -> Self {
150        self.start = Some(id.into());
151        self
152    }
153
154    /// Default ("next") edge from `from` → `to`.
155    pub fn edge(mut self, from: impl Into<NodeId>, to: impl Into<NodeId>) -> Self {
156        self.edges.entry(from.into()).or_default().push(EdgeDef {
157            name: None,
158            target: to.into(),
159        });
160        self
161    }
162
163    /// Named edge from `from` →(name)→ `to`. Matches `Transition::Edge("name")`.
164    pub fn edge_named(
165        mut self,
166        from: impl Into<NodeId>,
167        name: impl Into<String>,
168        to: impl Into<NodeId>,
169    ) -> Self {
170        self.edges.entry(from.into()).or_default().push(EdgeDef {
171            name: Some(name.into()),
172            target: to.into(),
173        });
174        self
175    }
176
177    /// Fallback node + retry cap when `from` fails.
178    pub fn branch_on_failure(
179        mut self,
180        from: impl Into<NodeId>,
181        fallback: impl Into<NodeId>,
182        retry_cap: u32,
183    ) -> Self {
184        self.failure.insert(
185            from.into(),
186            FailurePolicy {
187                fallback: Some(fallback.into()),
188                retry_cap,
189            },
190        );
191        self
192    }
193
194    /// Execute the blueprint.
195    pub async fn run(&self, world: &mut World) -> Result<BlueprintOutcome, HarnessError> {
196        let mut current = self
197            .start
198            .clone()
199            .ok_or_else(|| HarnessError::Other("blueprint has no start node".into()))?;
200        let mut visited: Vec<NodeId> = Vec::new();
201        let mut retries: HashMap<NodeId, u32> = HashMap::new();
202
203        loop {
204            tracing::debug!(node = %current, "blueprint enter");
205            let node = self.nodes.get(&current).ok_or_else(|| {
206                HarnessError::Other(format!("blueprint: node `{current}` not found"))
207            })?;
208            let result = match node {
209                Node::Deterministic(f) => f(world).await,
210                Node::Agent(f) => f(world).await,
211            };
212            visited.push(current.clone());
213            let out = match result {
214                Ok(o) => o,
215                Err(e) => {
216                    if let Some(pol) = self.failure.get(&current).cloned()
217                        && let Some(fallback) = pol.fallback.clone()
218                    {
219                        let count = retries.entry(current.clone()).or_insert(0);
220                        if *count < pol.retry_cap {
221                            *count += 1;
222                            tracing::warn!(node = %current, retry = *count, "node failed; rerunning");
223                            continue;
224                        }
225                        tracing::warn!(node = %current, fallback = %fallback, "retries exhausted, branching");
226                        current = fallback;
227                        continue;
228                    }
229                    return Err(e);
230                }
231            };
232            match out.transition {
233                Transition::Done => {
234                    return Ok(BlueprintOutcome {
235                        visited,
236                        last: out.data,
237                    });
238                }
239                Transition::Abort(reason) => return Err(HarnessError::Policy(reason)),
240                Transition::Edge(name) => {
241                    let next = self
242                        .edges
243                        .get(&current)
244                        .and_then(|es| es.iter().find(|e| e.name.as_deref() == Some(&name)))
245                        .map(|e| e.target.clone())
246                        .ok_or_else(|| {
247                            HarnessError::Other(format!(
248                                "blueprint: node `{current}` has no edge named `{name}`"
249                            ))
250                        })?;
251                    current = next;
252                }
253                Transition::Next => {
254                    let next = self
255                        .edges
256                        .get(&current)
257                        .and_then(|es| es.first())
258                        .map(|e| e.target.clone());
259                    match next {
260                        Some(t) => current = t,
261                        None => {
262                            return Ok(BlueprintOutcome {
263                                visited,
264                                last: out.data,
265                            });
266                        }
267                    }
268                }
269            }
270        }
271    }
272}
273
274#[derive(Debug, Clone)]
275pub struct BlueprintOutcome {
276    pub visited: Vec<NodeId>,
277    pub last: serde_json::Value,
278}
279
280#[cfg(test)]
281mod tests {
282    use super::*;
283    use serde_json::json;
284
285    fn mk_world() -> World {
286        harness_context::default_world(".")
287    }
288
289    #[tokio::test]
290    async fn linear_chain_runs_in_order() {
291        let bp = Blueprint::new()
292            .add(
293                "a",
294                Node::deterministic(|_w| {
295                    Box::pin(async move {
296                        Ok(NodeOutput {
297                            transition: Transition::Next,
298                            data: json!({"step":"a"}),
299                        })
300                    })
301                }),
302            )
303            .add(
304                "b",
305                Node::deterministic(|_w| {
306                    Box::pin(async move {
307                        Ok(NodeOutput {
308                            transition: Transition::Next,
309                            data: json!({"step":"b"}),
310                        })
311                    })
312                }),
313            )
314            .add(
315                "c",
316                Node::deterministic(|_w| {
317                    Box::pin(async move {
318                        Ok(NodeOutput {
319                            transition: Transition::Done,
320                            data: json!({"step":"c"}),
321                        })
322                    })
323                }),
324            )
325            .edge("a", "b")
326            .edge("b", "c");
327        let mut w = mk_world();
328        let out = bp.run(&mut w).await.unwrap();
329        assert_eq!(out.visited, vec!["a", "b", "c"]);
330        assert_eq!(out.last["step"], "c");
331    }
332
333    #[tokio::test]
334    async fn branch_on_failure_recovers() {
335        use std::sync::Arc;
336        use std::sync::atomic::{AtomicU32, Ordering};
337        let attempts = Arc::new(AtomicU32::new(0));
338        let attempts2 = attempts.clone();
339        let bp = Blueprint::new()
340            .add(
341                "flaky",
342                Node::deterministic(move |_w| {
343                    let attempts = attempts2.clone();
344                    Box::pin(async move {
345                        let n = attempts.fetch_add(1, Ordering::SeqCst);
346                        if n < 1 {
347                            Err(HarnessError::Other("transient".into()))
348                        } else {
349                            Ok(NodeOutput {
350                                transition: Transition::Done,
351                                data: json!({}),
352                            })
353                        }
354                    })
355                }),
356            )
357            .add(
358                "fallback",
359                Node::deterministic(|_w| {
360                    Box::pin(async move {
361                        Ok(NodeOutput {
362                            transition: Transition::Done,
363                            data: json!({"recovered": true}),
364                        })
365                    })
366                }),
367            )
368            .branch_on_failure("flaky", "fallback", 2);
369        let mut w = mk_world();
370        let out = bp.run(&mut w).await.unwrap();
371        // First attempt failed, second succeeded — we should have visited "flaky" twice.
372        assert_eq!(
373            out.visited.iter().filter(|n| n.as_str() == "flaky").count(),
374            2
375        );
376        assert_eq!(attempts.load(Ordering::SeqCst), 2);
377    }
378
379    #[tokio::test]
380    async fn named_edges_route_via_transition() {
381        let bp = Blueprint::new()
382            .add(
383                "router",
384                Node::deterministic(|_w| {
385                    Box::pin(async move {
386                        Ok(NodeOutput {
387                            transition: Transition::Edge("left".into()),
388                            data: json!({}),
389                        })
390                    })
391                }),
392            )
393            .add(
394                "left",
395                Node::deterministic(|_w| {
396                    Box::pin(async move {
397                        Ok(NodeOutput {
398                            transition: Transition::Done,
399                            data: json!({"branch":"left"}),
400                        })
401                    })
402                }),
403            )
404            .add(
405                "right",
406                Node::deterministic(|_w| {
407                    Box::pin(async move {
408                        Ok(NodeOutput {
409                            transition: Transition::Done,
410                            data: json!({"branch":"right"}),
411                        })
412                    })
413                }),
414            )
415            .edge_named("router", "left", "left")
416            .edge_named("router", "right", "right");
417        let mut w = mk_world();
418        let out = bp.run(&mut w).await.unwrap();
419        assert_eq!(out.visited, vec!["router", "left"]);
420    }
421}