Skip to main content

dora_message/
descriptor.rs

1#![warn(missing_docs)]
2
3use crate::{
4    config::{ByteSize, CommunicationConfig, Input, NodeRunConfig},
5    id::{DataId, NodeId, OperatorId},
6};
7use schemars::JsonSchema;
8use serde::{Deserialize, Serialize};
9use serde_with_expand_env::with_expand_envs;
10use std::{
11    collections::{BTreeMap, BTreeSet},
12    fmt,
13    path::PathBuf,
14};
15
16/// Wire framing mode for an output.
17#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
18#[serde(rename_all = "kebab-case")]
19pub enum OutputFraming {
20    /// Raw Arrow buffer layout (default, current behavior).
21    #[default]
22    Raw,
23    /// Arrow IPC stream format — self-describing, schema + record batches.
24    ArrowIpc,
25}
26
27/// Source identifier for shell-based nodes.
28pub const SHELL_SOURCE: &str = "shell";
29/// Set the [`Node::path`] field to this value to treat the node as a
30/// [_dynamic node_](https://docs.rs/dora-node-api/latest/dora_node_api/).
31pub const DYNAMIC_SOURCE: &str = "dynamic";
32
33/// # Dataflow Specification
34///
35/// The main configuration structure for defining a Dora dataflow. Dataflows are
36/// specified through YAML files that describe the nodes, their connections, and
37/// execution parameters.
38///
39/// ## Structure
40///
41/// A dataflow consists of:
42/// - **Nodes**: The computational units that process data
43/// - **Communication**: Optional communication configuration
44/// - **Deployment**: Optional deployment configuration (unstable)
45/// - **Debug options**: Optional development and debugging settings (unstable)
46///
47/// ## Example
48///
49/// ```yaml
50/// nodes:
51///  - id: webcam
52///     operator:
53///       python: webcam.py
54///       inputs:
55///         tick: dora/timer/millis/100
56///       outputs:
57///         - image
58///   - id: plot
59///     operator:
60///       python: plot.py
61///       inputs:
62///         image: webcam/image
63/// ```
64#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
65#[serde(deny_unknown_fields)]
66#[schemars(title = "dora-rs specification")]
67pub struct Descriptor {
68    /// List of nodes in the dataflow
69    ///
70    /// This is the most important field of the dataflow specification.
71    /// Each node must be identified by a unique `id`:
72    ///
73    /// ## Example
74    ///
75    /// ```yaml
76    /// nodes:
77    ///   - id: foo
78    ///     path: path/to/the/executable
79    ///     # ... (see below)
80    ///   - id: bar
81    ///     path: path/to/another/executable
82    ///     # ... (see below)
83    /// ```
84    ///
85    /// For each node, you need to specify the `path` of the executable or script that Dora should run when starting the node.
86    /// Most of the other node fields are optional, but you typically want to specify at least some `inputs` and/or `outputs`.
87    pub nodes: Vec<Node>,
88
89    /// Communication configuration (optional, uses defaults)
90    #[schemars(skip)]
91    #[serde(default)]
92    pub communication: CommunicationConfig,
93
94    /// Deployment configuration (optional, unstable)
95    #[schemars(skip)]
96    #[serde(rename = "_unstable_deploy")]
97    pub deploy: Option<Deploy>,
98
99    /// Debug options (optional, unstable)
100    #[schemars(skip)]
101    #[serde(default, rename = "_unstable_debug")]
102    pub debug: Debug,
103
104    /// How often the daemon checks node health (in seconds).
105    ///
106    /// Defaults to 5.0 seconds if not specified. Lower values detect hung nodes
107    /// faster but add more overhead.
108    #[serde(default)]
109    pub health_check_interval: Option<f64>,
110
111    /// Enable strict type checking: type warnings become errors during build.
112    ///
113    /// Can also be enabled via `--strict-types` CLI flag on `dora build`.
114    #[serde(default, skip_serializing_if = "Option::is_none")]
115    pub strict_types: Option<bool>,
116
117    /// Custom type compatibility rules.
118    ///
119    /// Each rule declares that a source type can be implicitly converted to
120    /// a target type. These supplement the built-in widening rules.
121    ///
122    /// ## Example
123    ///
124    /// ```yaml
125    /// type_rules:
126    ///   - from: myproject/SensorV1
127    ///     to: myproject/SensorV2
128    /// ```
129    #[serde(default, skip_serializing_if = "Vec::is_empty")]
130    pub type_rules: Vec<TypeRuleDef>,
131
132    /// Global environment variables inherited by every node.
133    ///
134    /// Each node's own `env` map takes precedence on key conflicts, so nodes
135    /// can override a global default without repeating shared values like
136    /// `RUST_LOG`, `OTEL_EXPORTER_OTLP_ENDPOINT`, or `CUDA_VISIBLE_DEVICES`.
137    ///
138    /// ## Example
139    ///
140    /// ```yaml
141    /// env:
142    ///   RUST_LOG: info
143    ///   OTEL_EXPORTER_OTLP_ENDPOINT: http://collector:4317
144    /// nodes:
145    ///   - id: verbose-node
146    ///     path: path/to/node
147    ///     env:
148    ///       RUST_LOG: debug  # overrides the global RUST_LOG for this node
149    /// ```
150    #[serde(default, skip_serializing_if = "Option::is_none")]
151    pub env: Option<BTreeMap<String, EnvValue>>,
152}
153
154/// A type compatibility rule declared in the dataflow YAML.
155#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
156#[serde(deny_unknown_fields)]
157pub struct TypeRuleDef {
158    /// Source type URN
159    pub from: String,
160    /// Target type URN
161    pub to: String,
162}
163
164/// Specifies when a node should be restarted.
165#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema)]
166#[serde(rename_all = "kebab-case")]
167pub enum RestartPolicy {
168    /// Never restart the node (default)
169    #[default]
170    Never,
171    /// Restart the node if it exits with a non-zero exit code.
172    OnFailure,
173    /// Always restart the node when it exits, regardless of exit code.
174    ///
175    /// The node will not be restarted on the following conditions:
176    ///
177    /// - The node was stopped by the user (e.g., via `dora stop`).
178    /// - All inputs to the node have been closed and the node finished with a non-zero exit code.
179    Always,
180}
181
182/// Deployment configuration for distributing nodes across machines.
183#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
184#[serde(deny_unknown_fields)]
185pub struct Deploy {
186    /// Target machine for deployment
187    pub machine: Option<String>,
188    /// Working directory for the deployment
189    pub working_dir: Option<PathBuf>,
190    /// Labels for label-based scheduling (e.g. `gpu: "true"`, `arch: arm64`).
191    /// The coordinator matches these against daemon labels reported at registration.
192    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
193    pub labels: BTreeMap<String, String>,
194    /// How built binaries are distributed to remote daemons.
195    #[serde(default)]
196    pub distribute: DistributeStrategy,
197}
198
199/// Strategy for distributing built binaries to daemons.
200#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
201#[serde(rename_all = "lowercase")]
202pub enum DistributeStrategy {
203    /// Each daemon builds from source (current/default behavior).
204    #[default]
205    Local,
206    /// CLI pushes built binary via SSH/SCP before spawn.
207    Scp,
208    /// Daemon pulls binary from coordinator HTTP artifact store before spawn.
209    Http,
210}
211
212/// Debug options for dataflow development and troubleshooting.
213#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
214pub struct Debug {
215    /// When true, daemons mirror every node output to the coordinator WebSocket
216    /// so that `dora topic echo`, `dora topic hz`, and `dora topic info` can
217    /// inspect runtime messages.
218    ///
219    /// The field was previously named `publish_all_messages_to_zenoh` (from
220    /// before the CLI inspection path moved off zenoh in PR #238). Serde still
221    /// accepts the old name as an alias for backward compatibility with
222    /// existing dataflow YAML; the alias will be removed in a future release.
223    #[serde(default, alias = "publish_all_messages_to_zenoh")]
224    pub enable_debug_inspection: bool,
225}
226
227/// # Dora Node Configuration
228///
229/// A node represents a computational unit in a Dora dataflow. Each node runs as a
230/// separate process and can communicate with other nodes through inputs and outputs.
231#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
232#[serde(deny_unknown_fields)]
233pub struct Node {
234    /// Unique node identifier. Must not contain `/` characters.
235    ///
236    /// Node IDs can be arbitrary strings with the following limitations:
237    ///
238    /// - They must not contain any `/` characters (slashes).
239    /// - We do not recommend using whitespace characters (e.g. spaces) in IDs
240    ///
241    /// Each node must have an ID field.
242    ///
243    /// ## Example
244    ///
245    /// ```yaml
246    /// nodes:
247    ///   - id: camera_node
248    ///   - id: some_other_node
249    /// ```
250    pub id: NodeId,
251
252    /// Human-readable node name for documentation.
253    ///
254    /// This optional field can be used to define a more descriptive name in addition to a short
255    /// [`id`](Self::id).
256    ///
257    /// ## Example
258    ///
259    /// ```yaml
260    /// nodes:
261    ///   - id: camera_node
262    ///     name: "Camera Input Handler"
263    pub name: Option<String>,
264
265    /// Detailed description of the node's functionality.
266    ///
267    /// ## Example
268    ///
269    /// ```yaml
270    /// nodes:
271    ///   - id: camera_node
272    ///     description: "Captures video frames from webcam"
273    /// ```
274    pub description: Option<String>,
275
276    /// Path to executable or script that should be run.
277    ///
278    /// Specifies the path of the executable or script that Dora should run when starting the
279    /// dataflow.
280    /// This can point to a normal executable (e.g. when using a compiled language such as Rust) or
281    /// a Python script.
282    ///
283    /// Dora will automatically append a `.exe` extension on Windows systems when the specified
284    /// file name has no extension.
285    ///
286    /// ## Example
287    ///
288    /// ```yaml
289    /// nodes:
290    ///   - id: rust-example
291    ///     path: target/release/rust-node
292    ///   - id: python-example
293    ///     path: ./receive_data.py
294    /// ```
295    ///
296    /// ## URL as Path
297    ///
298    /// The `path` field can also point to a URL instead of a local path.
299    /// In this case, Dora will download the given file when starting the dataflow.
300    ///
301    /// Note that this is quite an old feature and using this functionality is **not recommended**
302    /// anymore. Instead, we recommend using a [`git`][Self::git] and/or [`build`](Self::build)
303    /// key.
304    #[serde(default, skip_serializing_if = "Option::is_none")]
305    pub path: Option<String>,
306
307    /// SHA-256 checksum the `path` download must match, verified after fetch
308    /// and on cache reuse (spec §8.2/§8.4). Set internally when a `hub:`
309    /// reference resolves to a prebuilt binary artifact; rarely set by hand.
310    #[serde(default, skip_serializing_if = "Option::is_none")]
311    pub path_sha256: Option<String>,
312
313    /// Command-line arguments passed to the executable.
314    ///
315    /// The command-line arguments that should be passed to the executable/script specified in `path`.
316    /// The arguments should be separated by space.
317    /// This field is optional and defaults to an empty argument list.
318    ///
319    /// ## Example
320    /// ```yaml
321    /// nodes:
322    ///   - id: example
323    ///     path: example-node
324    ///     args: -v --some-flag foo
325    /// ```
326    #[serde(default, skip_serializing_if = "Option::is_none")]
327    pub args: Option<String>,
328
329    /// Environment variables for node builds and execution.
330    ///
331    /// Key-value map of environment variables that should be set for both the
332    /// [`build`](Self::build) operation and the node execution (i.e. when the node is spawned
333    /// through [`path`](Self::path)).
334    ///
335    /// Supports strings, numbers, and booleans.
336    ///
337    /// ## Example
338    ///
339    /// ```yaml
340    /// nodes:
341    ///   - id: example-node
342    ///     path: path/to/node
343    ///     env:
344    ///       DEBUG: true
345    ///       PORT: 8080
346    ///       API_KEY: "secret-key"
347    /// ```
348    pub env: Option<BTreeMap<String, EnvValue>>,
349
350    /// Multiple operators running in a shared runtime process.
351    ///
352    /// Operators are an experimental, lightweight alternative to nodes.
353    /// Instead of running as a separate process, operators are linked into a runtime process.
354    /// This allows running multiple operators to share a single address space (not supported for
355    /// Python currently).
356    ///
357    /// Operators are defined as part of the node list, as children of a runtime node.
358    /// A runtime node is a special node that specifies no [`path`](Self::path) field, but contains
359    /// an `operators` field instead.
360    ///
361    /// ## Example
362    ///
363    /// ```yaml
364    /// nodes:
365    ///   - id: runtime-node
366    ///     operators:
367    ///       - id: processor
368    ///         python: process.py
369    /// ```
370    #[serde(default, skip_serializing_if = "Option::is_none")]
371    pub operators: Option<RuntimeNode>,
372
373    /// Single operator configuration.
374    ///
375    /// This is a convenience field for defining runtime nodes that contain only a single operator.
376    /// This field is an alternative to the [`operators`](Self::operators) field, which can be used
377    /// if there is only a single operator defined for the runtime node.
378    ///
379    /// ## Example
380    ///
381    /// ```yaml
382    /// nodes:
383    ///   - id: runtime-node
384    ///     operator:
385    ///       id: processor
386    ///       python: script.py
387    ///       outputs: [data]
388    /// ```
389    #[serde(default, skip_serializing_if = "Option::is_none")]
390    pub operator: Option<SingleOperatorDefinition>,
391
392    /// ROS2 bridge configuration (unstable).
393    ///
394    /// Declares this node as a ROS2 bridge that automatically subscribes to or
395    /// publishes on ROS2 topics. No custom code is needed -- the framework spawns
396    /// a bridge binary that converts between ROS2 DDS messages and Dora's Arrow
397    /// format.
398    ///
399    /// ## Example
400    ///
401    /// ```yaml
402    /// nodes:
403    ///   - id: camera_bridge
404    ///     ros2:
405    ///       topic: /camera/image_raw
406    ///       message_type: sensor_msgs/Image
407    ///       direction: subscribe
408    ///     outputs:
409    ///       - image
410    /// ```
411    #[serde(default, skip_serializing_if = "Option::is_none")]
412    pub ros2: Option<Ros2BridgeConfig>,
413
414    /// Legacy node configuration (deprecated).
415    ///
416    /// Please use the top-level [`path`](Self::path), [`args`](Self::args), etc. fields instead.
417    #[serde(default, skip_serializing_if = "Option::is_none")]
418    pub custom: Option<CustomNode>,
419
420    /// Output data identifiers produced by this node.
421    ///
422    /// List of output identifiers that the node sends.
423    /// Must contain all `output_id` values that the node uses when sending output, e.g. through the
424    /// [`send_output`](https://docs.rs/dora-node-api/latest/dora_node_api/struct.DoraNode.html#method.send_output)
425    /// function.
426    ///
427    /// ## Example
428    ///
429    /// ```yaml
430    /// nodes:
431    ///   - id: example-node
432    ///     outputs:
433    ///       - processed_image
434    ///       - metadata
435    /// ```
436    #[serde(default)]
437    pub outputs: BTreeSet<DataId>,
438
439    /// Optional type annotations for outputs.
440    ///
441    /// Maps output identifiers to type URNs (e.g. `std/media/v1/Image`).
442    /// Only annotated outputs are type-checked; unannotated outputs remain dynamic.
443    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
444    pub output_types: BTreeMap<DataId, String>,
445
446    /// Per-output framing overrides (default: Raw for all).
447    ///
448    /// Maps output identifiers to their wire framing mode.
449    /// Outputs not listed here use the default `Raw` framing.
450    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
451    pub output_framing: BTreeMap<DataId, OutputFraming>,
452
453    /// Input data connections from other nodes.
454    ///
455    /// Defines the inputs that this node is subscribing to.
456    ///
457    /// The `inputs` field should be a key-value map of the following format:
458    ///
459    /// `input_id: source_node_id/source_node_output_id`
460    ///
461    /// The components are defined as follows:
462    ///
463    ///   - `input_id` is the local identifier that should be used for this input.
464    ///
465    ///     This will map to the `id` field of
466    ///     [`Event::Input`](https://docs.rs/dora-node-api/latest/dora_node_api/enum.Event.html#variant.Input)
467    ///     events sent to the node event loop.
468    ///   - `source_node_id` should be the `id` field of the node that sends the output that we want
469    ///     to subscribe to
470    ///   - `source_node_output_id` should be the identifier of the output that that we want
471    ///     to subscribe to
472    ///
473    /// ## Example
474    ///
475    /// ```yaml
476    /// nodes:
477    ///   - id: example-node
478    ///     outputs:
479    ///       - one
480    ///       - two
481    ///   - id: receiver
482    ///     inputs:
483    ///         my_input: example-node/two
484    /// ```
485    #[serde(default)]
486    pub inputs: BTreeMap<DataId, Input>,
487
488    /// Optional type annotations for inputs.
489    ///
490    /// Maps input identifiers to expected type URNs. Used by `dora validate`
491    /// to check that upstream output types match expectations.
492    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
493    pub input_types: BTreeMap<DataId, String>,
494
495    /// Required metadata keys per output.
496    ///
497    /// Maps output identifiers to lists of required metadata key names.
498    /// These are checked at build/validate time.
499    ///
500    /// ## Example
501    ///
502    /// ```yaml
503    /// output_metadata:
504    ///   response: [request_id]
505    /// ```
506    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
507    pub output_metadata: BTreeMap<DataId, Vec<String>>,
508
509    /// Communication pattern shorthand (e.g. `service-server`).
510    ///
511    /// Automatically implies required metadata keys on all outputs.
512    /// See `pattern_metadata_keys()` for supported patterns.
513    #[serde(default, skip_serializing_if = "Option::is_none")]
514    pub pattern: Option<String>,
515
516    /// Redirect stdout/stderr to a data output.
517    ///
518    /// This field can be used to send all stdout and stderr output of the node as a Dora output.
519    /// Each output line is sent as a separate message.
520    ///
521    ///
522    /// ## Example
523    ///
524    /// ```yaml
525    /// nodes:
526    ///   - id: example
527    ///     send_stdout_as: stdout_output
528    ///   - id: logger
529    ///     inputs:
530    ///         example_output: example/stdout_output
531    /// ```
532    #[serde(skip_serializing_if = "Option::is_none")]
533    pub send_stdout_as: Option<String>,
534
535    /// Redirect structured log entries to a data output as JSON strings.
536    ///
537    /// Unlike `send_stdout_as` which sends raw stdout lines, this sends only
538    /// parsed structured log entries (with level, timestamp, message, fields).
539    ///
540    /// ## Example
541    ///
542    /// ```yaml
543    /// nodes:
544    ///   - id: sensor
545    ///     path: ./sensor
546    ///     send_logs_as: logs
547    ///     outputs:
548    ///       - data
549    ///       - logs
550    /// ```
551    #[serde(skip_serializing_if = "Option::is_none")]
552    pub send_logs_as: Option<String>,
553
554    /// Minimum log level for this node (error, warn, info, debug, trace, stdout).
555    ///
556    /// Logs below this level are suppressed from file output, coordinator
557    /// forwarding, and `send_logs_as` routing.
558    ///
559    /// ## Example
560    ///
561    /// ```yaml
562    /// nodes:
563    ///   - id: noisy_sensor
564    ///     path: ./sensor
565    ///     min_log_level: info
566    /// ```
567    #[serde(skip_serializing_if = "Option::is_none")]
568    pub min_log_level: Option<String>,
569
570    /// Maximum log file size before rotation (e.g. "50MB", "1GB").
571    ///
572    /// When the JSONL log file exceeds this size, it is rotated. Old files
573    /// are renamed with numeric suffixes (`.1.jsonl`, `.2.jsonl`, etc.) and
574    /// the oldest are deleted once 5 rotated files exist.
575    ///
576    /// ## Example
577    ///
578    /// ```yaml
579    /// nodes:
580    ///   - id: sensor
581    ///     path: ./sensor
582    ///     max_log_size: "100MB"
583    /// ```
584    #[serde(skip_serializing_if = "Option::is_none")]
585    pub max_log_size: Option<String>,
586    /// Maximum number of rotated log files to keep (default: 5)
587    #[serde(default, skip_serializing_if = "Option::is_none")]
588    pub max_rotated_files: Option<u32>,
589
590    /// Build commands executed during `dora build`. Each line runs separately.
591    ///
592    /// The `build` key specifies the command that should be invoked for building the node.
593    /// The key expects a single- or multi-line string.
594    ///
595    /// Each line is run as a separate command.
596    /// Spaces are used to separate arguments.
597    ///
598    /// Note that all the environment variables specified in the [`env`](Self::env) field are also
599    /// applied to the build commands.
600    ///
601    /// ## Special treatment of `pip`
602    ///
603    /// Build lines that start with `pip` or `pip3` are treated in a special way:
604    /// If the `--uv` argument is passed to the `dora build` command, all `pip`/`pip3` commands are
605    /// run through the [`uv` package manager](https://docs.astral.sh/uv/).
606    ///
607    /// ## Example
608    ///
609    /// ```yaml
610    /// nodes:
611    /// - id: build-example
612    ///   build: cargo build -p receive_data --release
613    ///   path: target/release/receive_data
614    /// - id: multi-line-example
615    ///   build: |
616    ///       pip install requirements.txt
617    ///       pip install -e some/local/package
618    ///   path: package
619    /// ```
620    ///
621    /// In the above example, the `pip` commands will be replaced by `uv pip` when run through
622    /// `dora build --uv`.
623    #[serde(default, skip_serializing_if = "Option::is_none")]
624    pub build: Option<String>,
625
626    /// Git repository URL for downloading nodes.
627    ///
628    /// The `git` key allows downloading nodes (i.e. their source code) from git repositories.
629    /// This can be especially useful for distributed dataflows.
630    ///
631    /// When a `git` key is specified, `dora build` automatically clones the specified repository
632    /// (or reuse an existing clone).
633    /// Then it checks out the specified [`branch`](Self::branch), [`tag`](Self::tag), or
634    /// [`rev`](Self::rev), or the default branch if none of them are specified.
635    /// Afterwards it runs the [`build`](Self::build) command if specified.
636    ///
637    /// Note that the git clone directory is set as working directory for both the
638    /// [`build`](Self::build) command and the specified [`path`](Self::path).
639    ///
640    /// ## Example
641    ///
642    /// ```yaml
643    /// nodes:
644    ///   - id: rust-node
645    ///     git: https://github.com/dora-rs/dora.git
646    ///     build: cargo build -p rust-dataflow-example-node
647    ///     path: target/debug/rust-dataflow-example-node
648    /// ```
649    ///
650    /// In the above example, `dora build` will first clone the specified `git` repository and then
651    /// run the specified `build` inside the local clone directory.
652    /// When `dora run` or `dora start` is invoked, the working directory will be the git clone
653    /// directory too. So a relative `path` will start from the clone directory.
654    #[serde(default, skip_serializing_if = "Option::is_none")]
655    pub git: Option<String>,
656
657    /// Hub package reference (unstable).
658    ///
659    /// References a node published in the Dora Hub index:
660    /// `[<namespace>/]<name>@<semver-requirement>`. A bare name is shorthand
661    /// for the official `dora-rs/` namespace.
662    ///
663    /// `dora build` resolves the reference against the index to a pinned
664    /// commit and the node is fetched/built through the same machinery as a
665    /// [`git`](Self::git) node; the package manifest supplies the
666    /// entrypoint, build command, and typed contracts. Mutually exclusive
667    /// with `path`, `git`, and `build`.
668    ///
669    /// ## Example
670    ///
671    /// ```yaml
672    /// nodes:
673    ///   - id: detector
674    ///     hub: dora-yolo@^0.5
675    ///     inputs:
676    ///       image: camera/image
677    ///     outputs:
678    ///       - bbox
679    /// ```
680    #[serde(default, skip_serializing_if = "Option::is_none")]
681    pub hub: Option<String>,
682
683    /// Git branch to checkout after cloning.
684    ///
685    /// The `branch` field is only allowed in combination with the [`git`](#git) field.
686    /// It specifies the branch that should be checked out after cloning.
687    /// Only one of `branch`, `tag`, or `rev` can be specified.
688    ///
689    /// ## Example
690    ///
691    /// ```yaml
692    /// nodes:
693    ///   - id: rust-node
694    ///     git: https://github.com/dora-rs/dora.git
695    ///     branch: some-branch-name
696    /// ```
697    #[serde(default, skip_serializing_if = "Option::is_none")]
698    pub branch: Option<String>,
699
700    /// Git tag to checkout after cloning.
701    ///
702    /// The `tag` field is only allowed in combination with the [`git`](#git) field.
703    /// It specifies the git tag that should be checked out after cloning.
704    /// Only one of `branch`, `tag`, or `rev` can be specified.
705    ///
706    /// ## Example
707    ///
708    /// ```yaml
709    /// nodes:
710    ///   - id: rust-node
711    ///     git: https://github.com/dora-rs/dora.git
712    ///     tag: v0.1.0
713    /// ```
714    #[serde(default, skip_serializing_if = "Option::is_none")]
715    pub tag: Option<String>,
716
717    /// Git revision (e.g. commit hash) to checkout after cloning.
718    ///
719    /// The `rev` field is only allowed in combination with the [`git`](#git) field.
720    /// It specifies the git revision (e.g. a commit hash) that should be checked out after cloning.
721    /// Only one of `branch`, `tag`, or `rev` can be specified.
722    ///
723    /// ## Example
724    ///
725    /// ```yaml
726    /// nodes:
727    ///   - id: rust-node
728    ///     git: https://github.com/dora-rs/dora.git
729    ///     rev: 64ab0d7c
730    /// ```
731    #[serde(default, skip_serializing_if = "Option::is_none")]
732    pub rev: Option<String>,
733
734    /// Whether this node should be restarted on exit or error.
735    ///
736    /// Defaults to `RestartPolicy::Never`.
737    #[serde(default)]
738    pub restart_policy: RestartPolicy,
739
740    /// Size of the zenoh shared memory pool for zero-copy output publishing.
741    ///
742    /// Accepts an integer (raw bytes) or a string with a unit suffix
743    /// (`KB`, `MB`, `GB`, case-insensitive). If unset, the
744    /// `DORA_NODE_SHM_POOL_SIZE` env var is used, falling back to a
745    /// built-in default.
746    ///
747    /// ## Example
748    ///
749    /// ```yaml
750    /// nodes:
751    ///   - id: camera-node
752    ///     shared_memory_pool_size: 128MB
753    /// ```
754    #[serde(default, skip_serializing_if = "Option::is_none")]
755    pub shared_memory_pool_size: Option<ByteSize>,
756
757    /// Maximum number of restart attempts. 0 means unlimited.
758    ///
759    /// When combined with `restart_window`, this limits restarts within the window period.
760    /// For example, `max_restarts: 5` with `restart_window: 300` means "5 restarts per 5 minutes".
761    #[serde(default)]
762    pub max_restarts: u32,
763
764    /// Initial delay in seconds before restarting. Doubles each attempt (exponential backoff).
765    ///
766    /// For example, with `restart_delay: 1.0`, delays will be 1s, 2s, 4s, 8s, ...
767    /// Use `max_restart_delay` to cap the backoff.
768    #[serde(default, skip_serializing_if = "Option::is_none")]
769    pub restart_delay: Option<f64>,
770
771    /// Maximum delay in seconds for exponential backoff.
772    ///
773    /// Caps the exponentially growing `restart_delay`. For example, with
774    /// `restart_delay: 1.0` and `max_restart_delay: 30.0`, delays grow as
775    /// 1s, 2s, 4s, 8s, 16s, 30s, 30s, ...
776    #[serde(default, skip_serializing_if = "Option::is_none")]
777    pub max_restart_delay: Option<f64>,
778
779    /// Time window in seconds for counting restarts.
780    ///
781    /// When set, the restart counter resets after this period of time elapses since the
782    /// first restart in the current window. This enables "N restarts within M seconds" semantics.
783    #[serde(default, skip_serializing_if = "Option::is_none")]
784    pub restart_window: Option<f64>,
785
786    /// Health check timeout in seconds.
787    ///
788    /// When set, the daemon monitors this node for activity. If the node does not
789    /// communicate with the daemon within this timeout, it is killed and the restart
790    /// policy is evaluated.
791    #[serde(default, skip_serializing_if = "Option::is_none")]
792    pub health_check_timeout: Option<f64>,
793
794    /// Per-node finish-drain grace period in seconds.
795    ///
796    /// Overrides the global `DORA_FINISH_DRAIN_GRACE_SECS` for this node only.
797    /// When all other nodes in a dataflow have finished, the daemon waits this
798    /// long after the node's last input closes before force-stopping it.
799    ///
800    /// Set to a large value (e.g. `3600.0`) for nodes that need significant
801    /// post-input compute time (ML training, large-batch inference, checkpoint
802    /// writes) to prevent premature SIGKILL while the computation is in progress.
803    ///
804    /// When unset, the global grace period applies (default 120s, controlled
805    /// by `DORA_FINISH_DRAIN_GRACE_SECS`).
806    #[serde(default, skip_serializing_if = "Option::is_none")]
807    pub finish_grace_secs: Option<f64>,
808
809    /// Path to a module definition file (e.g. `nav_module.yml`).
810    ///
811    /// A module is a reusable sub-dataflow: a group of nodes with declared
812    /// inputs and outputs. At build time the module is expanded inline —
813    /// internal node IDs are prefixed with `{module_id}.` and all wiring is
814    /// rewritten so the runtime sees only flat nodes.
815    ///
816    /// Mutually exclusive with `path`, `operators`, `operator`, `custom`,
817    /// and `ros2`.
818    ///
819    /// ## Example
820    ///
821    /// ```yaml
822    /// nodes:
823    ///   - id: nav_stack
824    ///     module: modules/navigation_module.yml
825    ///     inputs:
826    ///       goal_pose: localization/goal
827    /// ```
828    #[serde(default, skip_serializing_if = "Option::is_none")]
829    pub module: Option<String>,
830
831    /// Parameters passed to a module for compile-time substitution.
832    ///
833    /// Only meaningful when `module` is set. Values are substituted into
834    /// inner node `args` fields (using `${_param.name}` syntax) and can be
835    /// injected into inner node `env` maps.
836    ///
837    /// ## Example
838    ///
839    /// ```yaml
840    /// nodes:
841    ///   - id: nav_stack
842    ///     module: modules/navigation_module.yml
843    ///     params:
844    ///       speed: "2.0"
845    ///       mode: turbo
846    /// ```
847    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
848    pub params: BTreeMap<String, String>,
849
850    /// CPU cores to pin this node's process to (Linux only, ignored on other platforms).
851    ///
852    /// ## Example
853    ///
854    /// ```yaml
855    /// nodes:
856    ///   - id: fast_node
857    ///     path: ./fast_node
858    ///     cpu_affinity: [0, 1]
859    /// ```
860    #[serde(default, skip_serializing_if = "Option::is_none")]
861    pub cpu_affinity: Option<Vec<usize>>,
862
863    /// Unstable machine deployment configuration
864    #[schemars(skip)]
865    #[serde(rename = "_unstable_deploy")]
866    pub deploy: Option<Deploy>,
867}
868
869#[allow(missing_docs)]
870#[derive(Debug, Clone, Serialize, Deserialize)]
871pub struct ResolvedNode {
872    pub id: NodeId,
873    pub name: Option<String>,
874    pub description: Option<String>,
875    pub env: Option<BTreeMap<String, EnvValue>>,
876
877    #[serde(default)]
878    pub cpu_affinity: Option<Vec<usize>>,
879
880    #[serde(default)]
881    pub deploy: Option<Deploy>,
882
883    #[serde(flatten)]
884    pub kind: CoreNodeKind,
885}
886
887#[allow(missing_docs)]
888impl ResolvedNode {
889    pub fn has_git_source(&self) -> bool {
890        self.kind
891            .as_custom()
892            .map(|n| n.source.is_git())
893            .unwrap_or_default()
894    }
895}
896
897#[allow(missing_docs)]
898#[derive(Debug, Clone, Serialize, Deserialize)]
899#[serde(rename_all = "lowercase")]
900#[allow(clippy::large_enum_variant)]
901pub enum CoreNodeKind {
902    /// Dora runtime node
903    #[serde(rename = "operators")]
904    Runtime(RuntimeNode),
905    Custom(CustomNode),
906}
907
908#[allow(missing_docs)]
909impl CoreNodeKind {
910    pub fn as_custom(&self) -> Option<&CustomNode> {
911        match self {
912            CoreNodeKind::Runtime(_) => None,
913            CoreNodeKind::Custom(custom_node) => Some(custom_node),
914        }
915    }
916}
917
918#[allow(missing_docs)]
919#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
920#[serde(transparent)]
921pub struct RuntimeNode {
922    /// List of operators running in this runtime
923    pub operators: Vec<OperatorDefinition>,
924}
925
926#[allow(missing_docs)]
927#[derive(Debug, Serialize, Deserialize, JsonSchema, Clone)]
928pub struct OperatorDefinition {
929    /// Unique operator identifier within the runtime
930    pub id: OperatorId,
931    #[serde(flatten)]
932    pub config: OperatorConfig,
933}
934
935#[allow(missing_docs)]
936#[derive(Debug, Serialize, Deserialize, JsonSchema, Clone)]
937pub struct SingleOperatorDefinition {
938    /// Operator identifier (optional for single operators)
939    pub id: Option<OperatorId>,
940    #[serde(flatten)]
941    pub config: OperatorConfig,
942}
943
944#[allow(missing_docs)]
945#[derive(Debug, Serialize, Deserialize, JsonSchema, Clone)]
946pub struct OperatorConfig {
947    /// Human-readable operator name
948    pub name: Option<String>,
949    /// Detailed description of the operator
950    pub description: Option<String>,
951
952    /// Input data connections
953    #[serde(default)]
954    pub inputs: BTreeMap<DataId, Input>,
955    /// Output data identifiers
956    #[serde(default)]
957    pub outputs: BTreeSet<DataId>,
958    /// Optional type annotations for outputs
959    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
960    pub output_types: BTreeMap<DataId, String>,
961
962    /// Per-output framing overrides (default: Raw for all).
963    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
964    pub output_framing: BTreeMap<DataId, OutputFraming>,
965
966    /// Optional type annotations for inputs
967    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
968    pub input_types: BTreeMap<DataId, String>,
969
970    /// Required metadata keys per output
971    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
972    pub output_metadata: BTreeMap<DataId, Vec<String>>,
973
974    /// Communication pattern shorthand (e.g. `service-server`)
975    #[serde(default, skip_serializing_if = "Option::is_none")]
976    pub pattern: Option<String>,
977
978    /// Operator source configuration (Python, shared library, etc.)
979    #[serde(flatten)]
980    pub source: OperatorSource,
981
982    /// Build commands for this operator
983    #[serde(default, skip_serializing_if = "Option::is_none")]
984    pub build: Option<String>,
985    /// Redirect stdout to data output
986    #[serde(skip_serializing_if = "Option::is_none")]
987    pub send_stdout_as: Option<String>,
988    /// Redirect structured log entries to a data output as JSON strings
989    #[serde(skip_serializing_if = "Option::is_none")]
990    pub send_logs_as: Option<String>,
991    /// Minimum log level for this operator
992    #[serde(skip_serializing_if = "Option::is_none")]
993    pub min_log_level: Option<String>,
994    /// Maximum log file size before rotation (e.g. "50MB", "1GB")
995    #[serde(skip_serializing_if = "Option::is_none")]
996    pub max_log_size: Option<String>,
997    /// Maximum number of rotated log files to keep (default: 5)
998    #[serde(default, skip_serializing_if = "Option::is_none")]
999    pub max_rotated_files: Option<u32>,
1000}
1001
1002#[allow(missing_docs)]
1003#[derive(Debug, Serialize, Deserialize, JsonSchema, Clone)]
1004#[serde(rename_all = "kebab-case")]
1005pub enum OperatorSource {
1006    SharedLibrary(String),
1007    Python(PythonSource),
1008    #[schemars(skip)]
1009    Wasm(String),
1010}
1011#[allow(missing_docs)]
1012#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1013#[serde(from = "PythonSourceDef", into = "PythonSourceDef")]
1014pub struct PythonSource {
1015    pub source: String,
1016    pub conda_env: Option<String>,
1017}
1018
1019#[allow(missing_docs)]
1020#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1021#[serde(untagged)]
1022pub enum PythonSourceDef {
1023    SourceOnly(String),
1024    WithOptions {
1025        source: String,
1026        conda_env: Option<String>,
1027    },
1028}
1029
1030impl From<PythonSource> for PythonSourceDef {
1031    fn from(input: PythonSource) -> Self {
1032        match input {
1033            PythonSource {
1034                source,
1035                conda_env: None,
1036            } => Self::SourceOnly(source),
1037            PythonSource { source, conda_env } => Self::WithOptions { source, conda_env },
1038        }
1039    }
1040}
1041
1042impl From<PythonSourceDef> for PythonSource {
1043    fn from(value: PythonSourceDef) -> Self {
1044        match value {
1045            PythonSourceDef::SourceOnly(source) => Self {
1046                source,
1047                conda_env: None,
1048            },
1049            PythonSourceDef::WithOptions { source, conda_env } => Self { source, conda_env },
1050        }
1051    }
1052}
1053
1054#[allow(missing_docs)]
1055#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1056pub struct CustomNode {
1057    /// Path of the source code
1058    ///
1059    /// If you want to use a specific `conda` environment.
1060    /// Provide the python path within the source.
1061    ///
1062    /// source: /home/peter/miniconda3/bin/python
1063    ///
1064    /// args: some_node.py
1065    ///
1066    /// Source can match any executable in PATH.
1067    pub path: String,
1068    pub source: NodeSource,
1069    /// SHA-256 the `path` download must match (set for hub binary artifacts,
1070    /// spec §8.2). When present the daemon fetches `path` as a verified URL
1071    /// download regardless of confinement — the checksum is the trust anchor.
1072    #[serde(default, skip_serializing_if = "Option::is_none")]
1073    pub path_sha256: Option<String>,
1074    /// Args for the executable.
1075    #[serde(default, skip_serializing_if = "Option::is_none")]
1076    pub args: Option<String>,
1077    /// Environment variables for the custom nodes
1078    ///
1079    /// Deprecated, use outer-level `env` field instead.
1080    pub envs: Option<BTreeMap<String, EnvValue>>,
1081    #[serde(default, skip_serializing_if = "Option::is_none")]
1082    pub build: Option<String>,
1083    /// Send stdout and stderr to another node
1084    #[serde(skip_serializing_if = "Option::is_none")]
1085    pub send_stdout_as: Option<String>,
1086    /// Redirect structured log entries to a data output as JSON strings
1087    #[serde(skip_serializing_if = "Option::is_none")]
1088    pub send_logs_as: Option<String>,
1089    /// Minimum log level for this node
1090    #[serde(skip_serializing_if = "Option::is_none")]
1091    pub min_log_level: Option<String>,
1092    /// Maximum log file size before rotation (e.g. "50MB", "1GB")
1093    #[serde(skip_serializing_if = "Option::is_none")]
1094    pub max_log_size: Option<String>,
1095    /// Maximum number of rotated log files to keep (default: 5)
1096    #[serde(default, skip_serializing_if = "Option::is_none")]
1097    pub max_rotated_files: Option<u32>,
1098
1099    #[serde(default)]
1100    pub restart_policy: RestartPolicy,
1101
1102    /// Maximum number of restart attempts. 0 means unlimited.
1103    #[serde(default)]
1104    pub max_restarts: u32,
1105
1106    /// Initial delay in seconds before restarting (exponential backoff).
1107    #[serde(default, skip_serializing_if = "Option::is_none")]
1108    pub restart_delay: Option<f64>,
1109
1110    /// Maximum delay in seconds for exponential backoff.
1111    #[serde(default, skip_serializing_if = "Option::is_none")]
1112    pub max_restart_delay: Option<f64>,
1113
1114    /// Time window in seconds for counting restarts.
1115    #[serde(default, skip_serializing_if = "Option::is_none")]
1116    pub restart_window: Option<f64>,
1117
1118    /// Health check timeout in seconds.
1119    ///
1120    /// When set, the daemon monitors this node for activity. If the node does not
1121    /// communicate with the daemon within this timeout, it is killed and the restart
1122    /// policy is evaluated.
1123    #[serde(default, skip_serializing_if = "Option::is_none")]
1124    pub health_check_timeout: Option<f64>,
1125
1126    /// Per-node finish-drain grace period in seconds.
1127    ///
1128    /// Overrides the global `DORA_FINISH_DRAIN_GRACE_SECS` for this node only.
1129    #[serde(default, skip_serializing_if = "Option::is_none")]
1130    pub finish_grace_secs: Option<f64>,
1131
1132    #[serde(flatten)]
1133    pub run_config: NodeRunConfig,
1134}
1135
1136#[allow(missing_docs)]
1137#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1138pub enum NodeSource {
1139    Local,
1140    GitBranch {
1141        repo: String,
1142        rev: Option<GitRepoRev>,
1143    },
1144}
1145
1146#[allow(missing_docs)]
1147impl NodeSource {
1148    pub fn is_git(&self) -> bool {
1149        matches!(self, Self::GitBranch { .. })
1150    }
1151}
1152
1153#[allow(missing_docs)]
1154#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1155pub enum GitRepoRev {
1156    Branch(String),
1157    Tag(String),
1158    Rev(String),
1159}
1160
1161#[allow(missing_docs)]
1162#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1163#[serde(untagged)]
1164pub enum EnvValue {
1165    #[serde(deserialize_with = "with_expand_envs")]
1166    Bool(bool),
1167    #[serde(deserialize_with = "with_expand_envs")]
1168    Integer(i64),
1169    #[serde(deserialize_with = "with_expand_envs")]
1170    Float(f64),
1171    #[serde(deserialize_with = "with_expand_envs")]
1172    String(String),
1173}
1174
1175impl fmt::Display for EnvValue {
1176    fn fmt(&self, fmt: &mut fmt::Formatter) -> fmt::Result {
1177        match self {
1178            EnvValue::Bool(bool) => fmt.write_str(&bool.to_string()),
1179            EnvValue::Integer(i64) => fmt.write_str(&i64.to_string()),
1180            EnvValue::Float(f64) => fmt.write_str(&f64.to_string()),
1181            EnvValue::String(str) => fmt.write_str(str),
1182        }
1183    }
1184}
1185
1186/// ROS2 bridge configuration for declarative ROS2 bridging.
1187///
1188/// This allows nodes to interact with ROS2 topics, services, and actions
1189/// without writing any custom code. The framework spawns a bridge binary that
1190/// handles the ROS2 DDS communication and Arrow data conversion.
1191///
1192/// Exactly one of `topic`, `topics`, `service`, or `action` must be set.
1193#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1194#[serde(deny_unknown_fields)]
1195pub struct Ros2BridgeConfig {
1196    /// ROS2 topic name (e.g. "/camera/image_raw").
1197    /// Mutually exclusive with `topics`, `service`, `action`.
1198    #[serde(default, skip_serializing_if = "Option::is_none")]
1199    pub topic: Option<String>,
1200
1201    /// ROS2 message type (e.g. "sensor_msgs/Image").
1202    /// Required when `topic` is set.
1203    #[serde(default, skip_serializing_if = "Option::is_none")]
1204    pub message_type: Option<String>,
1205
1206    /// Direction: subscribe (ROS2 -> Dora) or publish (Dora -> ROS2).
1207    /// Defaults to subscribe. Only used with `topic`/`topics`.
1208    #[serde(default)]
1209    pub direction: Ros2Direction,
1210
1211    /// Multiple topics on a single ROS2 node context.
1212    /// Mutually exclusive with `topic`, `service`, `action`.
1213    #[serde(default, skip_serializing_if = "Option::is_none")]
1214    pub topics: Option<Vec<Ros2TopicConfig>>,
1215
1216    /// ROS2 service name (e.g. "/add_two_ints").
1217    /// Mutually exclusive with `topic`, `topics`, `action`.
1218    #[serde(default, skip_serializing_if = "Option::is_none")]
1219    pub service: Option<String>,
1220
1221    /// ROS2 service type (e.g. "example_interfaces/AddTwoInts").
1222    /// Required when `service` is set.
1223    #[serde(default, skip_serializing_if = "Option::is_none")]
1224    pub service_type: Option<String>,
1225
1226    /// ROS2 action name (e.g. "/navigate").
1227    /// Mutually exclusive with `topic`, `topics`, `service`.
1228    #[serde(default, skip_serializing_if = "Option::is_none")]
1229    pub action: Option<String>,
1230
1231    /// ROS2 action type (e.g. "nav2_msgs/NavigateToPose").
1232    /// Required when `action` is set.
1233    #[serde(default, skip_serializing_if = "Option::is_none")]
1234    pub action_type: Option<String>,
1235
1236    /// Role: client or server. Required for `service` and `action`.
1237    #[serde(default, skip_serializing_if = "Option::is_none")]
1238    pub role: Option<Ros2Role>,
1239
1240    /// QoS policies applied to all topics (can be overridden per-topic).
1241    #[serde(default)]
1242    pub qos: Ros2QosConfig,
1243
1244    /// ROS2 namespace (default: "/").
1245    #[serde(default = "default_ros2_namespace")]
1246    pub namespace: String,
1247
1248    /// ROS2 node name. Defaults to the dora node id.
1249    #[serde(default, skip_serializing_if = "Option::is_none")]
1250    pub node_name: Option<String>,
1251}
1252
1253impl Default for Ros2BridgeConfig {
1254    fn default() -> Self {
1255        Self {
1256            topic: None,
1257            message_type: None,
1258            direction: Ros2Direction::default(),
1259            topics: None,
1260            service: None,
1261            service_type: None,
1262            action: None,
1263            action_type: None,
1264            role: None,
1265            qos: Ros2QosConfig::default(),
1266            namespace: default_ros2_namespace(),
1267            node_name: None,
1268        }
1269    }
1270}
1271
1272/// Role of a ROS2 service or action bridge node.
1273#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1274#[serde(rename_all = "snake_case")]
1275pub enum Ros2Role {
1276    /// Client: sends requests/goals, receives responses/results.
1277    Client,
1278    /// Server: receives requests, sends responses.
1279    Server,
1280}
1281
1282fn default_ros2_namespace() -> String {
1283    "/".to_string()
1284}
1285
1286/// Configuration for a single ROS2 topic in multi-topic mode.
1287#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1288#[serde(deny_unknown_fields)]
1289pub struct Ros2TopicConfig {
1290    /// ROS2 topic name.
1291    pub topic: String,
1292
1293    /// ROS2 message type (e.g. "geometry_msgs/Twist").
1294    pub message_type: String,
1295
1296    /// Direction: subscribe or publish.
1297    #[serde(default)]
1298    pub direction: Ros2Direction,
1299
1300    /// Maps to an dora output id (for subscribe direction).
1301    #[serde(default, skip_serializing_if = "Option::is_none")]
1302    pub output: Option<String>,
1303
1304    /// Maps to an dora input id (for publish direction).
1305    #[serde(default, skip_serializing_if = "Option::is_none")]
1306    pub input: Option<String>,
1307
1308    /// Per-topic QoS override.
1309    #[serde(default, skip_serializing_if = "Option::is_none")]
1310    pub qos: Option<Ros2QosConfig>,
1311}
1312
1313/// Direction of ROS2 bridge communication.
1314#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
1315#[serde(rename_all = "snake_case")]
1316pub enum Ros2Direction {
1317    /// Subscribe: receive from ROS2, forward to dora outputs.
1318    #[default]
1319    Subscribe,
1320    /// Publish: receive from dora inputs, publish to ROS2.
1321    Publish,
1322}
1323
1324/// ROS2 Quality of Service configuration.
1325#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
1326#[serde(deny_unknown_fields)]
1327pub struct Ros2QosConfig {
1328    /// Use reliable transport (default: false = best effort).
1329    #[serde(default)]
1330    pub reliable: bool,
1331
1332    /// Durability: "volatile" (default), "transient_local".
1333    #[serde(default, skip_serializing_if = "Option::is_none")]
1334    pub durability: Option<String>,
1335
1336    /// Liveliness: "automatic" (default), "manual_by_participant", "manual_by_topic".
1337    #[serde(default, skip_serializing_if = "Option::is_none")]
1338    pub liveliness: Option<String>,
1339
1340    /// Lease duration in seconds (default: infinity).
1341    #[serde(default, skip_serializing_if = "Option::is_none")]
1342    pub lease_duration: Option<f64>,
1343
1344    /// Max blocking time in seconds for reliable transport.
1345    #[serde(default, skip_serializing_if = "Option::is_none")]
1346    pub max_blocking_time: Option<f64>,
1347
1348    /// History depth for KeepLast policy (default: 1).
1349    #[serde(default, skip_serializing_if = "Option::is_none")]
1350    pub keep_last: Option<i32>,
1351
1352    /// Use KeepAll history policy instead of KeepLast.
1353    #[serde(default)]
1354    pub keep_all: bool,
1355}
1356
1357#[cfg(test)]
1358mod tests {
1359    use super::*;
1360
1361    #[test]
1362    fn output_framing_defaults_to_raw() {
1363        let yaml = r#"
1364nodes:
1365  - id: test
1366    path: test.py
1367    outputs:
1368      - data
1369"#;
1370        let desc: Descriptor = serde_yaml::from_str(yaml).unwrap();
1371        assert!(desc.nodes[0].output_framing.is_empty());
1372    }
1373
1374    #[test]
1375    fn output_framing_parses_arrow_ipc() {
1376        let yaml = r#"
1377nodes:
1378  - id: test
1379    path: test.py
1380    outputs:
1381      - data
1382    output_framing:
1383      data: arrow-ipc
1384"#;
1385        let desc: Descriptor = serde_yaml::from_str(yaml).unwrap();
1386        assert_eq!(
1387            desc.nodes[0].output_framing.get::<DataId>(&"data".into()),
1388            Some(&OutputFraming::ArrowIpc)
1389        );
1390    }
1391
1392    #[test]
1393    fn cpu_affinity_parses() {
1394        let yaml = r#"
1395nodes:
1396  - id: test
1397    path: test.py
1398    cpu_affinity: [0, 2, 4]
1399"#;
1400        let desc: Descriptor = serde_yaml::from_str(yaml).unwrap();
1401        assert_eq!(desc.nodes[0].cpu_affinity, Some(vec![0, 2, 4]));
1402    }
1403
1404    #[test]
1405    fn cpu_affinity_defaults_to_none() {
1406        let yaml = r#"
1407nodes:
1408  - id: test
1409    path: test.py
1410"#;
1411        let desc: Descriptor = serde_yaml::from_str(yaml).unwrap();
1412        assert_eq!(desc.nodes[0].cpu_affinity, None);
1413    }
1414
1415    #[test]
1416    fn debug_flag_accepts_new_name() {
1417        let yaml = r#"
1418nodes:
1419  - id: test
1420    path: test.py
1421_unstable_debug:
1422  enable_debug_inspection: true
1423"#;
1424        let desc: Descriptor = serde_yaml::from_str(yaml).unwrap();
1425        assert!(desc.debug.enable_debug_inspection);
1426    }
1427
1428    #[test]
1429    fn debug_flag_accepts_legacy_alias() {
1430        // Backward-compat regression guard (#240): dataflow YAML in the wild
1431        // still uses `publish_all_messages_to_zenoh`. The serde alias must
1432        // keep deserializing that into the renamed field.
1433        let yaml = r#"
1434nodes:
1435  - id: test
1436    path: test.py
1437_unstable_debug:
1438  publish_all_messages_to_zenoh: true
1439"#;
1440        let desc: Descriptor = serde_yaml::from_str(yaml).unwrap();
1441        assert!(desc.debug.enable_debug_inspection);
1442    }
1443}