Skip to main content

async_nodes/
def.rs

1//! Serializable graph definitions — the GUI / n8n-enabler.
2//!
3//! A [`GraphDef`] fully describes a graph as data: nodes (with stable string ids, opaque
4//! consumer-owned config blobs, and inert GUI layout positions), edges (with declarative
5//! conditions and loop budgets), and explicit entries. The fluent [`BspGraph`] builder
6//! remains the *authoring* API; definitions are the *interchange* format, and
7//! `def -> graph -> def` is a fixed point (modulo
8//! [`Custom`](crate::condition::Condition::Custom) closures, which serialize as their
9//! label only and fail closed at instantiation unless the label is re-registered as a
10//! named condition).
11//!
12//! `version` has existed since the first serialized file: bump [`GRAPH_DEF_VERSION`] on
13//! any breaking format change so old files are never unmarked.
14
15use std::collections::HashMap;
16
17use async_trait::async_trait;
18use serde::{Deserialize, Serialize};
19
20use super::{
21    BspContext,
22    condition::Condition,
23    error::BspError,
24    graph::{BspGraph, Edge, InputPin, NodeId},
25    node::{Activation, BspNode, NodeOutcome},
26    recovery::{Recovery, RecoveryPolicy, RetryPolicy},
27    registry::NodeRegistry,
28    run::RunContext,
29    value::BspValue,
30};
31
32/// Projects a static-input [`BspValue`] down to JSON for [`GraphDef`] serialization.
33///
34/// Covers the JSON-native types (`bool` / `i64` / `u64` / `f64` / `String` /
35/// `serde_json::Value`) — the exact set the default value factory produces, so
36/// `def -> graph -> def` is a fixed point for static inputs. Anything else returns
37/// `None` and is skipped by [`BspGraph::to_def`] (surfaced as a validation warning).
38pub(crate) fn value_to_json(value: &BspValue) -> Option<serde_json::Value> {
39    if let Some(v) = value.downcast_ref::<serde_json::Value>() {
40        Some(v.clone())
41    } else if let Some(b) = value.downcast_ref::<bool>() {
42        Some(serde_json::Value::from(*b))
43    } else if let Some(i) = value.downcast_ref::<i64>() {
44        Some(serde_json::Value::from(*i))
45    } else if let Some(u) = value.downcast_ref::<u64>() {
46        Some(serde_json::Value::from(*u))
47    } else if let Some(f) = value.downcast_ref::<f64>() {
48        // NaN/Inf have no JSON representation; skip rather than emit `null`.
49        serde_json::Number::from_f64(*f).map(serde_json::Value::Number)
50    } else {
51        value
52            .downcast_ref::<String>()
53            .map(|s| serde_json::Value::from(s.clone()))
54    }
55}
56
57/// The current [`GraphDef`] schema version. Bump on any breaking format change.
58pub const GRAPH_DEF_VERSION: u32 = 1;
59
60/// A serializable static input: an initial pin value injected before execution.
61///
62/// Values are JSON. Instantiation maps them back to typed [`BspValue`]s through the
63/// registry's value factory ([`crate::registry::default_value_factory`] by default).
64#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
65pub struct StaticInputDef {
66    /// Target node id (the stable string label).
67    pub node: String,
68    /// Target input port.
69    pub port: usize,
70    /// The value as JSON.
71    pub value: serde_json::Value,
72}
73
74/// A fully serializable graph description.
75#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
76pub struct GraphDef {
77    /// Schema version — present from day one so old files are never unmarked.
78    pub version: u32,
79    /// The node definitions, in declaration order.
80    pub nodes: Vec<NodeDef>,
81    /// The edge definitions, in declaration order.
82    pub edges: Vec<EdgeDef>,
83    /// Explicit entry node ids (in addition to no-incoming-edge sources).
84    #[serde(default, skip_serializing_if = "Vec::is_empty")]
85    pub entry: Vec<String>,
86    /// Static pin inputs, in declaration order.
87    #[serde(default, skip_serializing_if = "Vec::is_empty")]
88    pub static_inputs: Vec<StaticInputDef>,
89}
90
91impl GraphDef {
92    /// Parses a graph definition from JSON.
93    pub fn from_json(json: &str) -> Result<Self, BspError> {
94        serde_json::from_str(json).map_err(|e| BspError::DefError(e.to_string()))
95    }
96
97    /// Serializes this definition to pretty JSON.
98    pub fn to_json(&self) -> Result<String, BspError> {
99        serde_json::to_string_pretty(self).map_err(|e| BspError::DefError(e.to_string()))
100    }
101}
102
103/// A serializable node definition.
104#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
105pub struct NodeDef {
106    /// Stable string id — survives graph editing (deletion, copy-paste) untouched.
107    pub id: String,
108    /// Registry key, e.g. `"my-crate.generate"`.
109    pub type_name: String,
110    /// Node-specific configuration blob; the schema is owned (and versioned) by the
111    /// consumer's node type. The engine never looks inside.
112    #[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
113    pub config: serde_json::Value,
114    /// Activation override; absent means "the node type's own default".
115    #[serde(default, skip_serializing_if = "Option::is_none")]
116    pub activation: Option<ActivationDef>,
117    /// Recovery override; absent means "the node type's own policy". A `retry` here
118    /// reuses the node's own classifier when it has one, and otherwise retries every
119    /// failure — prefer registering classification inside the node factory.
120    #[serde(default, skip_serializing_if = "Option::is_none")]
121    pub recovery: Option<RecoveryDef>,
122    /// Optional named merger used when this node joins branch states.
123    #[serde(default, skip_serializing_if = "Option::is_none")]
124    pub merger: Option<String>,
125    /// Optional per-run execution cap (poll-until circuit breaker).
126    #[serde(default, skip_serializing_if = "Option::is_none")]
127    pub max_executions: Option<u64>,
128    /// Inert GUI layout hint; the engine ignores it and round-trips it faithfully so the
129    /// GUI never loses a human's arrangement.
130    #[serde(default, skip_serializing_if = "Option::is_none")]
131    pub position: Option<(f64, f64)>,
132}
133
134/// Serializable activation policy: `"any"` (control-flow default) or `"all"` (join).
135#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
136#[serde(rename_all = "snake_case")]
137pub enum ActivationDef {
138    /// Fire on the first pending token.
139    Any,
140    /// Fire when every incoming edge holds a pending token.
141    All,
142}
143
144/// Serializable recovery policy: `"fail_fast"`, `"error_edge"`, or
145/// `{"retry": {...}}` (backoff parameters only — classification never serializes).
146#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
147#[serde(rename_all = "snake_case")]
148pub enum RecoveryDef {
149    /// First error aborts the run.
150    FailFast,
151    /// Re-run with backoff on failure.
152    Retry {
153        /// Retry budget after the initial failure.
154        #[serde(default = "default_max_retries")]
155        max_retries: u32,
156        /// Initial backoff delay, in milliseconds.
157        #[serde(default = "default_base_delay_ms")]
158        base_delay_ms: u64,
159        /// Backoff ceiling, in milliseconds.
160        #[serde(default = "default_max_delay_ms")]
161        max_delay_ms: u64,
162        /// Symmetric jitter factor (e.g. `0.25` = ±25%).
163        #[serde(default = "default_jitter")]
164        jitter: f64,
165    },
166    /// Route failures to `on_error` edges instead of aborting.
167    ErrorEdge,
168}
169
170const fn default_max_retries() -> u32 {
171    3
172}
173const fn default_base_delay_ms() -> u64 {
174    500
175}
176const fn default_max_delay_ms() -> u64 {
177    8_000
178}
179const fn default_jitter() -> f64 {
180    0.25
181}
182
183/// The serializable condition subset.
184///
185/// Unit variants serialize as plain strings (`"always"`), compound variants as single-key
186/// objects (`{"named": "..."}`, `{"and": [...]}`). `Custom` keeps only its label and fails
187/// closed at instantiation unless the label is registered.
188#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
189#[serde(rename_all = "snake_case")]
190pub enum ConditionDef {
191    /// Always fires.
192    #[default]
193    Always,
194    /// Never fires.
195    Never,
196    /// Fires when the source execution carried a `BspSkip` marker.
197    OnSkip,
198    /// Fires when the source node failed and routes to error edges.
199    OnError,
200    /// A consumer-registered named predicate.
201    Named(String),
202    /// Conjunction.
203    And(Vec<ConditionDef>),
204    /// Disjunction.
205    Or(Vec<ConditionDef>),
206    /// Negation.
207    Not(Box<ConditionDef>),
208    /// An opaque custom predicate placeholder: label only, closure never serializes.
209    Custom {
210        /// The re-registration key.
211        label: String,
212    },
213}
214
215/// A serializable edge definition.
216#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
217pub struct EdgeDef {
218    /// Source node id.
219    pub from: String,
220    /// Target node id.
221    pub to: String,
222    /// The edge condition; defaults to `"always"`.
223    #[serde(default)]
224    pub condition: ConditionDef,
225    /// Iteration budget (expected on cycle-closing edges).
226    #[serde(default, skip_serializing_if = "Option::is_none")]
227    pub max_iterations: Option<u64>,
228    /// Routed output port (dataflow profile only).
229    #[serde(default, skip_serializing_if = "Option::is_none")]
230    pub source_port: Option<usize>,
231    /// Routed input port (dataflow profile only).
232    #[serde(default, skip_serializing_if = "Option::is_none")]
233    pub target_port: Option<usize>,
234}
235
236impl ConditionDef {
237    /// Instantiates a runtime condition, resolving `Custom` labels against the registry.
238    fn to_condition<S>(
239        &self,
240        registry: &NodeRegistry<S>,
241        unknown: &mut Vec<String>,
242    ) -> Condition<S> {
243        match self {
244            Self::Always => Condition::Always,
245            Self::Never => Condition::Never,
246            Self::OnSkip => Condition::OnSkip,
247            Self::OnError => Condition::OnError,
248            Self::Named(name) => {
249                if !registry.conditions.contains_key(name) {
250                    unknown.push(format!("condition '{name}'"));
251                }
252                Condition::Named(name.clone().into())
253            }
254            Self::And(conds) => Condition::And(
255                conds
256                    .iter()
257                    .map(|c| c.to_condition(registry, unknown))
258                    .collect(),
259            ),
260            Self::Or(conds) => Condition::Or(
261                conds
262                    .iter()
263                    .map(|c| c.to_condition(registry, unknown))
264                    .collect(),
265            ),
266            Self::Not(cond) => Condition::Not(Box::new(cond.to_condition(registry, unknown))),
267            Self::Custom { label } => match registry.conditions.get(label) {
268                // Fail closed unless the label was re-registered as a named condition.
269                Some(f) => Condition::Custom {
270                    label: label.clone(),
271                    f: f.clone(),
272                },
273                None => {
274                    unknown.push(format!(
275                        "custom condition '{label}' (re-register it to instantiate)"
276                    ));
277                    Condition::Never
278                }
279            },
280        }
281    }
282
283    /// Projects a runtime condition down to its serializable form.
284    fn from_condition<S>(condition: &Condition<S>) -> Self {
285        match condition {
286            Condition::Always => Self::Always,
287            Condition::Never => Self::Never,
288            Condition::OnSkip => Self::OnSkip,
289            Condition::OnError => Self::OnError,
290            Condition::Named(id) => Self::Named(id.0.clone()),
291            Condition::And(conds) => Self::And(conds.iter().map(Self::from_condition).collect()),
292            Condition::Or(conds) => Self::Or(conds.iter().map(Self::from_condition).collect()),
293            Condition::Not(cond) => Self::Not(Box::new(Self::from_condition(cond))),
294            Condition::Custom { label, .. } => Self::Custom {
295                label: label.clone(),
296            },
297        }
298    }
299}
300
301/// A node instantiated from a [`NodeDef`]: delegates to the factory-built node and applies
302/// the definition's overrides (activation, recovery, merger, execution cap).
303struct DefNode<S> {
304    inner: Box<dyn BspNode<S>>,
305    type_name: String,
306    activation: Option<Activation>,
307    recovery: Option<RecoveryPolicy>,
308    merger: Option<String>,
309    max_executions: Option<u64>,
310}
311
312#[async_trait]
313impl<S: Send + Sync> BspNode<S> for DefNode<S> {
314    fn name(&self) -> &str {
315        self.inner.name()
316    }
317
318    fn type_name(&self) -> &str {
319        &self.type_name
320    }
321
322    fn input_ports(&self) -> Vec<super::graph::PortIndex> {
323        self.inner.input_ports()
324    }
325
326    fn output_ports(&self) -> Vec<super::graph::PortIndex> {
327        self.inner.output_ports()
328    }
329
330    fn is_dynamic_output(&self) -> bool {
331        self.inner.is_dynamic_output()
332    }
333
334    fn activation(&self) -> Activation {
335        self.activation.unwrap_or_else(|| self.inner.activation())
336    }
337
338    fn recovery(&self) -> RecoveryPolicy {
339        match &self.recovery {
340            Some(policy) => policy.clone(),
341            None => self.inner.recovery(),
342        }
343    }
344
345    fn merger(&self) -> Option<&str> {
346        self.merger.as_deref().or_else(|| self.inner.merger())
347    }
348
349    fn max_executions(&self) -> Option<u64> {
350        self.max_executions.or_else(|| self.inner.max_executions())
351    }
352
353    async fn execute(&self, run: &mut RunContext<'_, S>) -> Result<NodeOutcome, BspError> {
354        self.inner.execute(run).await
355    }
356}
357
358impl From<ActivationDef> for Activation {
359    fn from(def: ActivationDef) -> Self {
360        match def {
361            ActivationDef::Any => Activation::Any,
362            ActivationDef::All => Activation::All,
363        }
364    }
365}
366
367impl From<Activation> for ActivationDef {
368    fn from(activation: Activation) -> Self {
369        match activation {
370            Activation::Any => ActivationDef::Any,
371            Activation::All => ActivationDef::All,
372        }
373    }
374}
375
376impl RecoveryDef {
377    /// Converts to the runtime retry policy parameters.
378    fn to_retry_policy(self) -> Option<RetryPolicy> {
379        match self {
380            Self::Retry {
381                max_retries,
382                base_delay_ms,
383                max_delay_ms,
384                jitter,
385            } => Some(RetryPolicy {
386                max_retries,
387                base_delay: std::time::Duration::from_millis(base_delay_ms),
388                max_delay: std::time::Duration::from_millis(max_delay_ms),
389                jitter,
390            }),
391            _ => None,
392        }
393    }
394}
395
396impl<S> BspGraph<S> {
397    /// Instantiates a graph from a serializable definition and a consumer registry.
398    ///
399    /// Registration is fail-fast: any unknown node `type_name`, condition name, custom
400    /// label, or merger name produces a [`BspError::ValidationError`] listing *all*
401    /// unknown names — never a mid-run surprise. Node and edge ids are mapped from the
402    /// definition's stable strings onto positional [`NodeId`]s, with the strings retained
403    /// as labels so [`BspGraph::to_def`] round-trips losslessly.
404    pub fn from_def(
405        def: &GraphDef,
406        registry: &NodeRegistry<S>,
407        ctx: &BspContext,
408    ) -> Result<Self, BspError>
409    where
410        S: Send + Sync + 'static,
411    {
412        if def.version != GRAPH_DEF_VERSION {
413            return Err(BspError::DefError(format!(
414                "unsupported graph definition version {} (this crate understands {GRAPH_DEF_VERSION})",
415                def.version
416            )));
417        }
418
419        // Registry completeness: collect *all* unknown names before failing.
420        let mut unknown: Vec<String> = Vec::new();
421        for node_def in &def.nodes {
422            if !registry.factories.contains_key(&node_def.type_name) {
423                unknown.push(format!("node type '{}'", node_def.type_name));
424            }
425            if let Some(merger) = &node_def.merger
426                && !registry.mergers.contains_key(merger)
427            {
428                unknown.push(format!("merger '{merger}'"));
429            }
430        }
431
432        // Map stable string ids onto positional node ids (declaration order).
433        let mut id_map: HashMap<&str, NodeId> = HashMap::new();
434        for (idx, node_def) in def.nodes.iter().enumerate() {
435            let id = idx + 1;
436            if id_map.insert(node_def.id.as_str(), id).is_some() {
437                return Err(BspError::ValidationError(format!(
438                    "duplicate node id '{}' in graph definition",
439                    node_def.id
440                )));
441            }
442        }
443
444        // Convert edges (conditions validate against the registry as they convert).
445        let mut edges: Vec<Edge<S>> = Vec::with_capacity(def.edges.len());
446        for edge_def in &def.edges {
447            let source = id_map.get(edge_def.from.as_str()).copied().ok_or_else(|| {
448                BspError::ValidationError(format!(
449                    "edge references unknown source node '{}'",
450                    edge_def.from
451                ))
452            })?;
453            let target = id_map.get(edge_def.to.as_str()).copied().ok_or_else(|| {
454                BspError::ValidationError(format!(
455                    "edge references unknown target node '{}'",
456                    edge_def.to
457                ))
458            })?;
459            edges.push(Edge {
460                source,
461                target,
462                condition: edge_def.condition.to_condition(registry, &mut unknown),
463                max_iterations: edge_def.max_iterations,
464                source_port: edge_def.source_port,
465                target_port: edge_def.target_port,
466            });
467        }
468
469        if !unknown.is_empty() {
470            return Err(BspError::ValidationError(format!(
471                "graph definition references unregistered names: {}",
472                unknown.join(", ")
473            )));
474        }
475
476        let mut graph = BspGraph::new();
477        graph.conditions = registry.conditions.clone();
478        graph.mergers = registry.mergers.clone();
479
480        for (idx, node_def) in def.nodes.iter().enumerate() {
481            let id = idx + 1;
482            let factory = registry
483                .factories
484                .get(&node_def.type_name)
485                .expect("completeness checked above");
486            let inner = factory(node_def, ctx)?;
487
488            // A def-level retry reuses the node's own classifier when it has one;
489            // otherwise it retries every failure (documented on `NodeDef::recovery`).
490            let recovery = match node_def.recovery {
491                None => None,
492                Some(RecoveryDef::FailFast) => Some(RecoveryPolicy::FailFast),
493                Some(RecoveryDef::ErrorEdge) => Some(RecoveryPolicy::RouteToErrorEdge),
494                Some(def @ RecoveryDef::Retry { .. }) => {
495                    let policy = def.to_retry_policy().expect("retry variant");
496                    let classify = match inner.recovery() {
497                        RecoveryPolicy::Retry { classify, .. } => classify,
498                        _ => std::sync::Arc::new(|_: &BspError| Recovery::Retry { after: None }),
499                    };
500                    Some(RecoveryPolicy::Retry { policy, classify })
501                }
502            };
503
504            graph.add_node_with_id(
505                id,
506                Box::new(DefNode {
507                    inner,
508                    type_name: node_def.type_name.clone(),
509                    activation: node_def.activation.map(Activation::from),
510                    recovery,
511                    merger: node_def.merger.clone(),
512                    max_executions: node_def.max_executions,
513                }),
514            );
515            graph.labels.insert(id, node_def.id.clone());
516            graph.node_defs.insert(id, node_def.clone());
517        }
518
519        for edge in edges {
520            graph.push_edge(edge);
521        }
522
523        for entry in &def.entry {
524            let id = id_map.get(entry.as_str()).copied().ok_or_else(|| {
525                BspError::ValidationError(format!("entry references unknown node '{entry}'"))
526            })?;
527            graph.add_entry(id);
528        }
529
530        // Static inputs: JSON values become typed pin values through the registry's
531        // value factory (see `registry::default_value_factory`).
532        for static_input in &def.static_inputs {
533            let id = id_map
534                .get(static_input.node.as_str())
535                .copied()
536                .ok_or_else(|| {
537                    BspError::ValidationError(format!(
538                        "static input references unknown node '{}'",
539                        static_input.node
540                    ))
541                })?;
542            graph.static_inputs.insert(
543                InputPin {
544                    node_id: id,
545                    port: static_input.port,
546                },
547                (registry.value_factory)(&static_input.value),
548            );
549        }
550
551        Ok(graph)
552    }
553
554    /// Projects this graph into a serializable definition.
555    ///
556    /// Def-built graphs re-emit their retained [`NodeDef`]s exactly, so
557    /// `def -> graph -> def` is a fixed point. Programmatically built graphs synthesize
558    /// node definitions from the live nodes (stable label ids, `type_name`s, current
559    /// activation / recovery / merger / execution caps, null configs).
560    #[must_use]
561    pub fn to_def(&self) -> GraphDef {
562        let nodes = self
563            .nodes
564            .iter()
565            .map(|(id, node)| {
566                if let Some(retained) = self.node_defs.get(id) {
567                    return retained.clone();
568                }
569                NodeDef {
570                    id: self
571                        .labels
572                        .get(id)
573                        .cloned()
574                        .unwrap_or_else(|| id.to_string()),
575                    type_name: node.type_name().to_string(),
576                    config: serde_json::Value::Null,
577                    activation: Some(node.activation().into()),
578                    recovery: Some(match node.recovery() {
579                        RecoveryPolicy::FailFast => RecoveryDef::FailFast,
580                        RecoveryPolicy::RouteToErrorEdge => RecoveryDef::ErrorEdge,
581                        RecoveryPolicy::Retry { policy, .. } => RecoveryDef::Retry {
582                            max_retries: policy.max_retries,
583                            base_delay_ms: u64::try_from(policy.base_delay.as_millis())
584                                .unwrap_or(u64::MAX),
585                            max_delay_ms: u64::try_from(policy.max_delay.as_millis())
586                                .unwrap_or(u64::MAX),
587                            jitter: policy.jitter,
588                        },
589                    }),
590                    merger: node.merger().map(str::to_string),
591                    max_executions: node.max_executions(),
592                    position: None,
593                }
594            })
595            .collect();
596
597        let edges = self
598            .edges
599            .iter()
600            .map(|edge| EdgeDef {
601                from: self
602                    .labels
603                    .get(&edge.source)
604                    .cloned()
605                    .unwrap_or_else(|| edge.source.to_string()),
606                to: self
607                    .labels
608                    .get(&edge.target)
609                    .cloned()
610                    .unwrap_or_else(|| edge.target.to_string()),
611                condition: ConditionDef::from_condition(&edge.condition),
612                max_iterations: edge.max_iterations,
613                source_port: edge.source_port,
614                target_port: edge.target_port,
615            })
616            .collect();
617
618        let entry = self
619            .entry
620            .iter()
621            .map(|id| {
622                self.labels
623                    .get(id)
624                    .cloned()
625                    .unwrap_or_else(|| id.to_string())
626            })
627            .collect();
628
629        // Static inputs: emit the JSON-representable ones; anything else is skipped
630        // here and surfaced as a warning by `validate_with_report`.
631        let static_inputs = self
632            .static_inputs
633            .iter()
634            .filter_map(|(pin, value)| {
635                value_to_json(value).map(|json| StaticInputDef {
636                    node: self
637                        .labels
638                        .get(&pin.node_id)
639                        .cloned()
640                        .unwrap_or_else(|| pin.node_id.to_string()),
641                    port: pin.port,
642                    value: json,
643                })
644            })
645            .collect();
646
647        GraphDef {
648            version: GRAPH_DEF_VERSION,
649            nodes,
650            edges,
651            entry,
652            static_inputs,
653        }
654    }
655}
656
657#[cfg(test)]
658mod tests {
659    use super::*;
660
661    #[test]
662    fn test_condition_def_json_shapes() {
663        assert_eq!(
664            serde_json::to_string(&ConditionDef::Always).unwrap(),
665            "\"always\""
666        );
667        assert_eq!(
668            serde_json::to_string(&ConditionDef::Named("has_more".to_string())).unwrap(),
669            "{\"named\":\"has_more\"}"
670        );
671        assert_eq!(
672            serde_json::to_string(&ConditionDef::Not(Box::new(ConditionDef::OnError))).unwrap(),
673            "{\"not\":\"on_error\"}"
674        );
675        assert_eq!(
676            serde_json::to_string(&ConditionDef::Custom {
677                label: "hand_rolled".to_string()
678            })
679            .unwrap(),
680            "{\"custom\":{\"label\":\"hand_rolled\"}}"
681        );
682    }
683
684    #[test]
685    fn test_activation_and_recovery_def_json_shapes() {
686        assert_eq!(
687            serde_json::to_string(&ActivationDef::All).unwrap(),
688            "\"all\""
689        );
690        assert_eq!(
691            serde_json::to_string(&RecoveryDef::FailFast).unwrap(),
692            "\"fail_fast\""
693        );
694        let retry = RecoveryDef::Retry {
695            max_retries: 2,
696            base_delay_ms: 100,
697            max_delay_ms: 1_000,
698            jitter: 0.1,
699        };
700        let json = serde_json::to_string(&retry).unwrap();
701        assert!(json.starts_with("{\"retry\":{"));
702        // Missing retry fields fall back to the shared defaults.
703        let parsed: RecoveryDef =
704            serde_json::from_str("{\"retry\": {\"max_retries\": 1}}").unwrap();
705        assert_eq!(
706            parsed.to_retry_policy().unwrap().base_delay,
707            std::time::Duration::from_millis(500)
708        );
709    }
710
711    #[test]
712    fn test_graph_def_version_roundtrip_and_mismatch() {
713        let json = r#"{"version": 1, "nodes": [], "edges": []}"#;
714        let def = GraphDef::from_json(json).unwrap();
715        assert_eq!(def.version, GRAPH_DEF_VERSION);
716        assert!(def.entry.is_empty());
717
718        let registry: NodeRegistry<()> = NodeRegistry::new();
719        let ctx = BspContext::new();
720        let result = BspGraph::from_def(
721            &GraphDef {
722                version: 99,
723                nodes: Vec::new(),
724                edges: Vec::new(),
725                entry: Vec::new(),
726                static_inputs: Vec::new(),
727            },
728            &registry,
729            &ctx,
730        );
731        assert!(matches!(result, Err(BspError::DefError(_))));
732    }
733}