1use futures::future::BoxFuture;
16use harness_core::{HarnessError, World};
17use serde::{Deserialize, Serialize};
18use std::collections::HashMap;
19
20pub type NodeId = String;
21
22#[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 Edge(String),
36 Next,
38 Done,
40 Abort(String),
42}
43
44pub enum Node {
46 Deterministic(BoxedDetermFn),
48 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 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#[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#[derive(Debug, Clone)]
103struct EdgeDef {
104 name: Option<String>,
106 target: NodeId,
107}
108
109#[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 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 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 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 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 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(¤t).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(¤t).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(¤t)
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(¤t)
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 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}