entelix_graph/state_graph.rs
1//! `StateGraph<S>` — the builder side of the graph contract.
2//!
3//! Surface:
4//!
5//! - `add_node(name, runnable)` — node is a `Runnable<S, S>` returning the
6//! new full state.
7//! - `add_edge(from, to)` — single static next-hop per node.
8//! - `add_conditional_edges(from, selector, mapping)` — predicate-based
9//! dispatch. The selector takes `&S`, returns a key, and the mapping
10//! resolves it to a target node (or [`END`]).
11//! - `add_send_edges(from, targets, selector, join)` — parallel
12//! fan-out. The selector returns `Vec<(target, branch_state)>`;
13//! each branch runs its target node concurrently, results fold
14//! via the state's [`StateMerge::merge`](crate::StateMerge::merge)
15//! impl into the pre-fan-out state, then control flows to `join`.
16//! The state struct supplies the merge story via
17//! `#[derive(StateMerge)]` over per-field
18//! [`Annotated<T, R>`](crate::Annotated) wrappers — adding new
19//! state fields never edits send-edge call sites.
20//! - `set_entry_point(name)` — required.
21//! - `add_finish_point(name)` — running this node halts and returns state.
22//! - `with_recursion_limit(n)` — F6 mitigation, default 25.
23//! - `compile() → CompiledGraph<S>` — preflight validation; the result
24//! implements `Runnable<S, S>` so it composes via `.pipe()` and serves
25//! as a sub-graph node in a larger `StateGraph`.
26
27use std::collections::{HashMap, HashSet};
28use std::sync::Arc;
29
30use entelix_core::{Error, Result};
31use entelix_runnable::Runnable;
32
33use crate::checkpoint::Checkpointer;
34use crate::compiled::{
35 CompiledGraph, ConditionalEdge, EdgeSelector, SendEdge, SendMerger, SendSelector,
36};
37use crate::contributing_node::ContributingNodeAdapter;
38use crate::merge_node::MergeNodeAdapter;
39use crate::reducer::StateMerge;
40
41/// Default cap on node executions per `invoke` (F6 mitigation — guards
42/// against infinite cycles).
43pub const DEFAULT_RECURSION_LIMIT: usize = 25;
44
45/// Sentinel target meaning "terminate without running another node". Use
46/// in `add_conditional_edges` mapping when a branch should end the graph.
47pub const END: &str = "__entelix_graph_end__";
48
49/// How often the compiled graph writes a checkpoint when a
50/// `Checkpointer` is attached.
51///
52/// `PerNode` (the default) writes after every successful node
53/// completion — durable enough that a crash mid-graph loses at most
54/// one node's work. `Off` skips checkpointer writes entirely; the
55/// graph still runs end-to-end but cannot resume after a crash.
56#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Hash)]
57#[non_exhaustive]
58pub enum CheckpointGranularity {
59 /// Skip checkpointer writes. Useful for ephemeral graphs or
60 /// when the checkpointer is attached purely to satisfy a
61 /// downstream API contract.
62 Off,
63 /// Write a checkpoint after each node completes successfully.
64 /// This is the default and matches the F8 mitigation.
65 #[default]
66 PerNode,
67}
68
69/// Builder for a state-machine graph parameterised over its state type `S`.
70///
71/// Nodes are `Runnable<S, S>` instances; each one consumes the current state
72/// and returns the new full state. Three node-registration shapes coexist:
73///
74/// - [`Self::add_node`] — full-state replace. The node owns the
75/// entire shape and returns the next state.
76/// - [`Self::add_node_with`] — delta + bespoke merger closure.
77/// Best when the merge logic is graph-specific.
78/// - [`Self::add_contributing_node`] — declarative per-field merge
79/// via the [`StateMerge`] trait. The state struct advertises its
80/// merge story (typically through `#[derive(StateMerge)]` and
81/// per-field [`Annotated<T, R>`](crate::Annotated) wrappers); the
82/// node returns an `Option`-wrapped `S::Contribution` naming
83/// exactly the slots it touched. Slots left as `None` keep the
84/// current value; slots set to `Some` merge through the
85/// per-field reducer.
86pub struct StateGraph<S>
87where
88 S: Clone + Send + Sync + 'static,
89{
90 nodes: HashMap<String, Arc<dyn Runnable<S, S>>>,
91 edges: HashMap<String, String>,
92 conditional_edges: HashMap<String, ConditionalEdge<S>>,
93 send_edges: HashMap<String, SendEdge<S>>,
94 entry_point: Option<String>,
95 finish_points: HashSet<String>,
96 recursion_limit: usize,
97 checkpointer: Option<Arc<dyn Checkpointer<S>>>,
98 checkpoint_granularity: CheckpointGranularity,
99 /// Nodes whose pre-execution position is a HITL pause point —
100 /// the runtime raises `Error::Interrupted` *before* invoking
101 /// the node, persists a checkpoint pointing back at the same
102 /// node, and lets the host application resume via
103 /// `Command::Resume` (re-runs the node) or `Command::Update`
104 /// (re-runs with new state).
105 interrupt_before: HashSet<String>,
106 /// Nodes whose post-execution position is a HITL pause point —
107 /// the runtime raises `Error::Interrupted` *after* the node
108 /// completes successfully, persists a checkpoint with the new
109 /// state pointing at the resolved next node, and lets the host
110 /// application resume forward (skipping the just-run node).
111 interrupt_after: HashSet<String>,
112}
113
114impl<S> std::fmt::Debug for StateGraph<S>
115where
116 S: Clone + Send + Sync + 'static,
117{
118 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
119 // Deterministic Debug output — see `CompiledGraph::fmt`
120 // for the rationale: HashMap / HashSet iteration order is
121 // unspecified, so sorted projections keep test snapshots
122 // and operator log diffs stable.
123 let mut nodes: Vec<&String> = self.nodes.keys().collect();
124 nodes.sort();
125 let mut edges: Vec<(&String, &String)> = self.edges.iter().collect();
126 edges.sort_by_key(|(k, _)| k.as_str());
127 let mut conditional: Vec<&String> = self.conditional_edges.keys().collect();
128 conditional.sort();
129 let mut send: Vec<&String> = self.send_edges.keys().collect();
130 send.sort();
131 let mut finish: Vec<&String> = self.finish_points.iter().collect();
132 finish.sort();
133 let mut interrupt_before: Vec<&String> = self.interrupt_before.iter().collect();
134 interrupt_before.sort();
135 let mut interrupt_after: Vec<&String> = self.interrupt_after.iter().collect();
136 interrupt_after.sort();
137 f.debug_struct("StateGraph")
138 .field("nodes", &nodes)
139 .field("edges", &edges)
140 .field("conditional_edges", &conditional)
141 .field("send_edges", &send)
142 .field("entry_point", &self.entry_point)
143 .field("finish_points", &finish)
144 .field("recursion_limit", &self.recursion_limit)
145 .field("has_checkpointer", &self.checkpointer.is_some())
146 .field("checkpoint_granularity", &self.checkpoint_granularity)
147 .field("interrupt_before", &interrupt_before)
148 .field("interrupt_after", &interrupt_after)
149 .finish()
150 }
151}
152
153impl<S> StateGraph<S>
154where
155 S: Clone + Send + Sync + 'static,
156{
157 /// Empty graph.
158 pub fn new() -> Self {
159 Self {
160 nodes: HashMap::new(),
161 edges: HashMap::new(),
162 conditional_edges: HashMap::new(),
163 send_edges: HashMap::new(),
164 entry_point: None,
165 finish_points: HashSet::new(),
166 recursion_limit: DEFAULT_RECURSION_LIMIT,
167 checkpointer: None,
168 checkpoint_granularity: CheckpointGranularity::default(),
169 interrupt_before: HashSet::new(),
170 interrupt_after: HashSet::new(),
171 }
172 }
173
174 /// Attach a checkpointer.
175 ///
176 /// When set, the compiled graph writes a checkpoint after every node
177 /// invocation if the executing `ExecutionContext` carries a
178 /// `thread_id`. Use [`CompiledGraph::resume`] to continue from the
179 /// most recent checkpoint after a crash. Tune the write frequency
180 /// via [`Self::with_checkpoint_granularity`].
181 #[must_use]
182 pub fn with_checkpointer(mut self, checkpointer: Arc<dyn Checkpointer<S>>) -> Self {
183 self.checkpointer = Some(checkpointer);
184 self
185 }
186
187 /// Override how often the compiled graph writes a checkpoint
188 /// when a checkpointer is attached. Defaults to
189 /// [`CheckpointGranularity::PerNode`].
190 #[must_use]
191 pub const fn with_checkpoint_granularity(mut self, g: CheckpointGranularity) -> Self {
192 self.checkpoint_granularity = g;
193 self
194 }
195
196 /// Register a node. A second registration with the same name replaces
197 /// the prior runnable (calls during construction are append-or-replace,
198 /// not append-only).
199 #[must_use]
200 pub fn add_node<R>(mut self, name: impl Into<String>, runnable: R) -> Self
201 where
202 R: Runnable<S, S> + 'static,
203 {
204 self.nodes.insert(name.into(), Arc::new(runnable));
205 self
206 }
207
208 /// Register a *delta-style* node. The inner runnable produces an
209 /// update of arbitrary type `U`; the merger combines it with the
210 /// inbound state into a fresh full state.
211 ///
212 /// Use this when the natural shape of a node is "compute and
213 /// return what changed" rather than "thread the entire state
214 /// through". The merger has access to both the snapshot of the
215 /// inbound state and the delta, so per-field
216 /// [`Reducer<T>`](crate::Reducer) calls (`Append`, `MergeMap`,
217 /// `Max`, …) plug in directly:
218 ///
219 /// ```ignore
220 /// graph.add_node_with(
221 /// "plan",
222 /// planner_runnable,
223 /// |mut state: PlanState, update: PlannerOutput| {
224 /// state.log = Append::<String>::new()
225 /// .reduce(state.log, update.new_log_entries);
226 /// state.iterations += 1;
227 /// Ok(state)
228 /// },
229 /// );
230 /// ```
231 ///
232 /// Existing [`Self::add_node`] (full-state replace) keeps working
233 /// unchanged — the two patterns coexist node-by-node.
234 #[must_use]
235 pub fn add_node_with<R, U, F>(self, name: impl Into<String>, runnable: R, merger: F) -> Self
236 where
237 R: Runnable<S, U> + 'static,
238 U: Send + Sync + 'static,
239 F: Fn(S, U) -> Result<S> + Send + Sync + 'static,
240 {
241 self.add_node(name, MergeNodeAdapter::new(runnable, merger))
242 }
243
244 /// Register a *contribution-style* node whose output names
245 /// exactly the slots it touched, folded into the current state
246 /// through [`StateMerge::merge_contribution`]. The inner
247 /// runnable returns `S::Contribution` — an `Option`-wrapped
248 /// shape mirroring LangGraph's TypedDict partial-return:
249 /// `None` slots keep the current value, `Some` slots merge
250 /// through the per-field reducer.
251 ///
252 /// Use this when the state type owns its merge story
253 /// declaratively (via `#[derive(StateMerge)]` over fields wrapped
254 /// in [`Annotated<T, R>`](crate::Annotated)). Adding a new
255 /// state field never edits the graph builder — the per-field
256 /// reducer annotation does the work.
257 ///
258 /// ```ignore
259 /// use entelix_graph::{Annotated, Append, Max, StateGraph, StateMerge};
260 /// use entelix_runnable::RunnableLambda;
261 ///
262 /// #[derive(Clone, Default, StateMerge)]
263 /// struct AgentState {
264 /// log: Annotated<Vec<String>, Append<String>>,
265 /// score: Annotated<i32, Max<i32>>,
266 /// last_message: String,
267 /// }
268 ///
269 /// // Node writes only `log` and `last_message`; `score`
270 /// // stays at whatever the upstream produced (the contribution
271 /// // shape carries `None` for it, which means "I didn't touch this").
272 /// let planner = RunnableLambda::new(|_state: AgentState, _ctx| async {
273 /// Ok(AgentStateContribution::default()
274 /// .with_log(vec!["planned".into()])
275 /// .with_last_message("scheduled".into()))
276 /// });
277 /// let graph = StateGraph::<AgentState>::new()
278 /// .add_contributing_node("planner", planner);
279 /// ```
280 #[must_use]
281 pub fn add_contributing_node<R>(self, name: impl Into<String>, runnable: R) -> Self
282 where
283 R: Runnable<S, S::Contribution> + 'static,
284 S: StateMerge,
285 {
286 self.add_node(name, ContributingNodeAdapter::new(runnable))
287 }
288
289 /// Register a static `from → to` edge. Calling twice with the same
290 /// `from` replaces the previous target — single static next-hop per
291 /// node.
292 ///
293 /// A node may not have both a static edge and a conditional edge; the
294 /// `compile()` step rejects that combination.
295 #[must_use]
296 pub fn add_edge(mut self, from: impl Into<String>, to: impl Into<String>) -> Self {
297 self.edges.insert(from.into(), to.into());
298 self
299 }
300
301 /// Register a conditional dispatch: after `from` runs, evaluate
302 /// `selector(&state)` and route to the node named by
303 /// `mapping[selector_output]`. Mapping targets may be node names or
304 /// [`END`].
305 ///
306 /// A second call with the same `from` replaces the prior conditional.
307 /// Mixing with `add_edge` on the same `from` is rejected at compile
308 /// time.
309 #[must_use]
310 pub fn add_conditional_edges<F, K, V>(
311 mut self,
312 from: impl Into<String>,
313 selector: F,
314 mapping: impl IntoIterator<Item = (K, V)>,
315 ) -> Self
316 where
317 F: Fn(&S) -> String + Send + Sync + 'static,
318 K: Into<String>,
319 V: Into<String>,
320 {
321 let mapping: HashMap<String, String> = mapping
322 .into_iter()
323 .map(|(k, v)| (k.into(), v.into()))
324 .collect();
325 let edge = ConditionalEdge {
326 selector: Arc::new(selector) as EdgeSelector<S>,
327 mapping,
328 };
329 self.conditional_edges.insert(from.into(), edge);
330 self
331 }
332
333 /// Register a parallel fan-out from `from`.
334 ///
335 /// `targets` lists every node the selector may dispatch to —
336 /// statically declared so `compile()` can validate each name
337 /// resolves to a registered node and so leaf-validation knows
338 /// these nodes have a defined control path (the fan-out merges
339 /// their results back into the join node, no per-branch
340 /// outgoing edge is required).
341 ///
342 /// After `from` runs, the runtime evaluates `selector(&state)`
343 /// to obtain a list of `(target_node, branch_state)` pairs.
344 /// Each branch is invoked in parallel; the resulting per-branch
345 /// states fold via `reducer` into a single `S`. Control then
346 /// flows to the `join` node, which sees the reduced state.
347 ///
348 /// Selector outputs that name a node not in `targets` cause a
349 /// runtime [`Error::InvalidRequest`] — typo-resistant by
350 /// construction.
351 ///
352 /// Mutually exclusive with [`Self::add_edge`] /
353 /// [`Self::add_conditional_edges`] on the same `from` — `compile`
354 /// rejects the combination. The join target must be registered
355 /// or [`END`].
356 #[must_use]
357 pub fn add_send_edges<F, I, T>(
358 mut self,
359 from: impl Into<String>,
360 targets: I,
361 selector: F,
362 join: impl Into<String>,
363 ) -> Self
364 where
365 F: Fn(&S) -> Vec<(String, S)> + Send + Sync + 'static,
366 I: IntoIterator<Item = T>,
367 T: Into<String>,
368 S: StateMerge,
369 {
370 let edge = SendEdge::new(
371 targets.into_iter().map(Into::into),
372 Arc::new(selector) as SendSelector<S>,
373 Arc::new(<S as StateMerge>::merge) as SendMerger<S>,
374 join.into(),
375 );
376 self.send_edges.insert(from.into(), edge);
377 self
378 }
379
380 /// Mark the start node. Required at compile time.
381 #[must_use]
382 pub fn set_entry_point(mut self, name: impl Into<String>) -> Self {
383 self.entry_point = Some(name.into());
384 self
385 }
386
387 /// Mark a node as terminal — running it halts the graph and returns
388 /// the post-node state. A graph may have more than one finish point;
389 /// any path that reaches one terminates.
390 #[must_use]
391 pub fn add_finish_point(mut self, name: impl Into<String>) -> Self {
392 self.finish_points.insert(name.into());
393 self
394 }
395
396 /// Override the per-invocation recursion limit (F6 mitigation).
397 #[must_use]
398 pub const fn with_recursion_limit(mut self, n: usize) -> Self {
399 self.recursion_limit = n;
400 self
401 }
402
403 /// Mark `nodes` as HITL pause points evaluated **before** the
404 /// node runs. When control reaches a marked node the runtime
405 /// raises `Error::Interrupted` with
406 /// `kind: InterruptionKind::ScheduledPause { phase: Before, node }`
407 /// (the `payload` is `Value::Null` — every distinguishing
408 /// detail is on the typed kind) and (when a `Checkpointer` is
409 /// attached) persists a checkpoint pointing back at the same
410 /// node.
411 ///
412 /// Resume via the existing `Command` machinery:
413 /// - `Command::Resume` re-runs the marked node from the saved
414 /// pre-state.
415 /// - `Command::Update(s)` re-runs the marked node from `s`.
416 /// - `Command::GoTo(other)` jumps to `other` instead.
417 ///
418 /// Calling twice unions the supplied node sets.
419 #[must_use]
420 pub fn interrupt_before<I, T>(mut self, nodes: I) -> Self
421 where
422 I: IntoIterator<Item = T>,
423 T: Into<String>,
424 {
425 self.interrupt_before
426 .extend(nodes.into_iter().map(Into::into));
427 self
428 }
429
430 /// Mark `nodes` as HITL pause points evaluated **after** the
431 /// node completes successfully. When such a node returns Ok
432 /// the runtime raises `Error::Interrupted` with
433 /// `kind: InterruptionKind::ScheduledPause { phase: After, node }`
434 /// and persists a checkpoint with the post-node state pointing
435 /// at the resolved next node — `Command::Resume` then continues
436 /// forward, skipping a re-run of the just-completed node.
437 #[must_use]
438 pub fn interrupt_after<I, T>(mut self, nodes: I) -> Self
439 where
440 I: IntoIterator<Item = T>,
441 T: Into<String>,
442 {
443 self.interrupt_after
444 .extend(nodes.into_iter().map(Into::into));
445 self
446 }
447
448 /// Number of registered nodes.
449 pub fn node_count(&self) -> usize {
450 self.nodes.len()
451 }
452
453 /// Number of registered static edges.
454 pub fn edge_count(&self) -> usize {
455 self.edges.len()
456 }
457
458 /// Number of nodes with a conditional dispatch.
459 pub fn conditional_edge_count(&self) -> usize {
460 self.conditional_edges.len()
461 }
462
463 /// Validate and freeze the graph.
464 ///
465 /// Returns `Err(Error::Config(_))` for:
466 /// - Missing entry point.
467 /// - Entry point referencing an unregistered node.
468 /// - Static edge referencing an unregistered `from` or `to`.
469 /// - Conditional edge `from` not registered, or any mapping target
470 /// that is neither a registered node nor [`END`].
471 /// - A node with both a static edge AND a conditional edge.
472 /// - No finish points registered.
473 /// - Finish point referencing an unregistered node.
474 /// - A non-finish-point node has no outgoing edge (static or
475 /// conditional).
476 /// - `interrupt_before` / `interrupt_after` referencing a node
477 /// that is not registered.
478 pub fn compile(self) -> Result<CompiledGraph<S>> {
479 let entry = self
480 .entry_point
481 .as_ref()
482 .ok_or_else(|| Error::config("StateGraph: no entry point set"))?
483 .clone();
484 if !self.nodes.contains_key(&entry) {
485 return Err(Error::config(format!(
486 "StateGraph: entry point '{entry}' is not a registered node"
487 )));
488 }
489 self.validate_finish_points()?;
490 self.validate_static_edges()?;
491 self.validate_conditional_edges()?;
492 let send_branch_targets = self.validate_send_edges()?;
493 self.validate_node_termination(&send_branch_targets)?;
494 self.validate_interrupt_points()?;
495
496 Ok(CompiledGraph::new(
497 self.nodes,
498 self.edges,
499 self.conditional_edges,
500 self.send_edges,
501 entry,
502 self.finish_points,
503 self.recursion_limit,
504 self.checkpointer,
505 self.checkpoint_granularity,
506 self.interrupt_before,
507 self.interrupt_after,
508 ))
509 }
510
511 /// Every name in `interrupt_before` / `interrupt_after` must
512 /// resolve to a registered node — typo-resistant by
513 /// construction.
514 fn validate_interrupt_points(&self) -> Result<()> {
515 for name in &self.interrupt_before {
516 if !self.nodes.contains_key(name) {
517 return Err(Error::config(format!(
518 "StateGraph: interrupt_before names '{name}' which is not a registered node"
519 )));
520 }
521 }
522 for name in &self.interrupt_after {
523 if !self.nodes.contains_key(name) {
524 return Err(Error::config(format!(
525 "StateGraph: interrupt_after names '{name}' which is not a registered node"
526 )));
527 }
528 }
529 Ok(())
530 }
531
532 /// Validate finish-point set: at least one, every entry must
533 /// resolve to a registered node.
534 fn validate_finish_points(&self) -> Result<()> {
535 if self.finish_points.is_empty() {
536 return Err(Error::config(
537 "StateGraph: no finish points registered (graph would never terminate)",
538 ));
539 }
540 for fp in &self.finish_points {
541 if !self.nodes.contains_key(fp) {
542 return Err(Error::config(format!(
543 "StateGraph: finish point '{fp}' is not a registered node"
544 )));
545 }
546 }
547 Ok(())
548 }
549
550 /// Validate static `from → to` edges.
551 fn validate_static_edges(&self) -> Result<()> {
552 for (from, to) in &self.edges {
553 if !self.nodes.contains_key(from) {
554 return Err(Error::config(format!(
555 "StateGraph: edge source '{from}' is not a registered node"
556 )));
557 }
558 if !self.nodes.contains_key(to) {
559 return Err(Error::config(format!(
560 "StateGraph: edge target '{to}' is not a registered node"
561 )));
562 }
563 }
564 Ok(())
565 }
566
567 /// Validate conditional-edge dispatch tables.
568 fn validate_conditional_edges(&self) -> Result<()> {
569 for (from, cond) in &self.conditional_edges {
570 if !self.nodes.contains_key(from) {
571 return Err(Error::config(format!(
572 "StateGraph: conditional edge source '{from}' is not a registered node"
573 )));
574 }
575 if self.edges.contains_key(from) {
576 return Err(Error::config(format!(
577 "StateGraph: node '{from}' has both a static edge and a conditional edge \
578 — pick one"
579 )));
580 }
581 for target in cond.mapping.values() {
582 if target != END && !self.nodes.contains_key(target) {
583 return Err(Error::config(format!(
584 "StateGraph: conditional edge from '{from}' maps to '{target}' which is \
585 neither a registered node nor END"
586 )));
587 }
588 }
589 }
590 Ok(())
591 }
592
593 /// Validate send-edge fan-outs and return the union of
594 /// statically-declared branch targets (used by leaf-validation
595 /// to recognise these nodes as having a defined control path).
596 fn validate_send_edges(&self) -> Result<HashSet<String>> {
597 let mut send_branch_targets: HashSet<String> = HashSet::new();
598 for (from, send) in &self.send_edges {
599 if !self.nodes.contains_key(from) {
600 return Err(Error::config(format!(
601 "StateGraph: send edge source '{from}' is not a registered node"
602 )));
603 }
604 if self.edges.contains_key(from) || self.conditional_edges.contains_key(from) {
605 return Err(Error::config(format!(
606 "StateGraph: node '{from}' has more than one outgoing edge type — \
607 send edges are mutually exclusive with static and conditional edges"
608 )));
609 }
610 if send.join != END && !self.nodes.contains_key(&send.join) {
611 return Err(Error::config(format!(
612 "StateGraph: send edge from '{from}' joins on '{}' which is \
613 neither a registered node nor END",
614 send.join
615 )));
616 }
617 for target in send.targets() {
618 if !self.nodes.contains_key(target) {
619 return Err(Error::config(format!(
620 "StateGraph: send edge from '{from}' lists target '{target}' \
621 which is not a registered node"
622 )));
623 }
624 send_branch_targets.insert(target.clone());
625 }
626 }
627 Ok(send_branch_targets)
628 }
629
630 /// Every non-finish node must have a defined control-flow path:
631 /// a static edge, a conditional edge, a send edge, or be the
632 /// dispatch target of someone else's send edge.
633 fn validate_node_termination(&self, send_branch_targets: &HashSet<String>) -> Result<()> {
634 for name in self.nodes.keys() {
635 if !self.finish_points.contains(name)
636 && !self.edges.contains_key(name)
637 && !self.conditional_edges.contains_key(name)
638 && !self.send_edges.contains_key(name)
639 && !send_branch_targets.contains(name)
640 {
641 return Err(Error::config(format!(
642 "StateGraph: node '{name}' has no outgoing edge and is not a finish point"
643 )));
644 }
645 }
646 Ok(())
647 }
648}
649
650impl<S> Default for StateGraph<S>
651where
652 S: Clone + Send + Sync + 'static,
653{
654 fn default() -> Self {
655 Self::new()
656 }
657}