Skip to main content

dora_message/
descriptor.rs

1#![warn(missing_docs)]
2
3use crate::{
4    config::{ByteSize, 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/// - **Deployment**: Optional deployment configuration (unstable)
44/// - **Debug options**: Optional development and debugging settings (unstable)
45///
46/// ## Example
47///
48/// ```
49/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
50/// use dora_message::descriptor::Descriptor;
51/// let yaml = r#"
52/// nodes:
53///   - id: webcam
54///     operator:
55///       python: webcam.py
56///       inputs:
57///         tick: dora/timer/millis/100
58///       outputs:
59///         - image
60///   - id: plot
61///     operator:
62///       python: plot.py
63///       inputs:
64///         image: webcam/image
65/// "#;
66/// let descriptor: Descriptor = serde_yaml::from_str(yaml)?;
67/// assert_eq!(descriptor.nodes.len(), 2);
68/// # Ok(())
69/// # }
70/// ```
71#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
72#[serde(deny_unknown_fields)]
73#[schemars(title = "dora-rs specification")]
74// Same rationale as `Node`: keeps a new *dataflow-level* key a minor release.
75// This is where `exit_when_nodes_finish`, `health_check_interval`, `type_rules`
76// and `strict_types` landed, so it grows at least as often as `Node` does.
77// Construct with `Descriptor::new`; the fields remain `pub`.
78#[non_exhaustive]
79pub struct Descriptor {
80    /// List of nodes in the dataflow
81    ///
82    /// This is the most important field of the dataflow specification.
83    /// Each node must be identified by a unique `id`:
84    ///
85    /// ## Example
86    ///
87    /// ```yaml
88    /// nodes:
89    ///   - id: foo
90    ///     path: path/to/the/executable
91    ///     # ... (see below)
92    ///   - id: bar
93    ///     path: path/to/another/executable
94    ///     # ... (see below)
95    /// ```
96    ///
97    /// For each node, you need to specify the `path` of the executable or script that Dora should run when starting the node.
98    /// Most of the other node fields are optional, but you typically want to specify at least some `inputs` and/or `outputs`.
99    pub nodes: Vec<Node>,
100
101    /// Deployment configuration (optional).
102    #[schemars(skip)]
103    pub deploy: Option<Deploy>,
104
105    /// Debug options (optional).
106    #[schemars(skip)]
107    #[serde(default)]
108    pub debug: Debug,
109
110    /// How often the daemon checks node health (in seconds).
111    ///
112    /// Defaults to 5.0 seconds if not specified. Lower values detect hung nodes
113    /// faster but add more overhead.
114    #[serde(default)]
115    pub health_check_interval: Option<f64>,
116
117    /// Enable strict type checking: type warnings become errors during build.
118    ///
119    /// Can also be enabled via `--strict-types` CLI flag on `dora build`.
120    #[serde(default, skip_serializing_if = "Option::is_none")]
121    pub strict_types: Option<bool>,
122
123    /// Finish the dataflow once every node has, treating
124    /// `dora/timer/...` inputs as a clock rather than as work.
125    ///
126    /// A timer input has no upstream node, so it never closes. By default
127    /// a node consuming one is therefore never told its inputs are done
128    /// and the graph cannot end on its own, even after every node doing
129    /// real work has exited (dora-rs/dora#2920).
130    ///
131    /// Off by default: for a long-lived dataflow the timer is precisely
132    /// what keeps it alive. Nodes with no data inputs at all (timer-only
133    /// sources, or no inputs) are unaffected either way -- they have no
134    /// dependency that could finish, so they are treated as sources.
135    ///
136    /// Set by `dora run --exit-when-nodes-finish` and `dora start
137    /// --exit-when-nodes-finish`, and settable directly in YAML. It lives
138    /// on the descriptor rather than on the wire so that it survives the
139    /// events a dataflow outlives: auto-recovery re-spawn, coordinator
140    /// restart with state reconstruction, and `dora restart`.
141    ///
142    /// ## Example
143    ///
144    /// ```yaml
145    /// exit_when_nodes_finish: true
146    /// nodes:
147    ///   - id: worker
148    ///     path: ./worker
149    ///     inputs:
150    ///       tick: dora/timer/millis/100
151    /// ```
152    #[serde(default, skip_serializing_if = "Option::is_none")]
153    pub exit_when_nodes_finish: Option<bool>,
154
155    /// Custom type compatibility rules.
156    ///
157    /// Each rule declares that a source type can be implicitly converted to
158    /// a target type. These supplement the built-in widening rules.
159    ///
160    /// ## Example
161    ///
162    /// ```yaml
163    /// type_rules:
164    ///   - from: myproject/SensorV1
165    ///     to: myproject/SensorV2
166    /// ```
167    #[serde(default, skip_serializing_if = "Vec::is_empty")]
168    pub type_rules: Vec<TypeRuleDef>,
169
170    /// Global environment variables inherited by every node.
171    ///
172    /// Each node's own `env` map takes precedence on key conflicts, so nodes
173    /// can override a global default without repeating shared values like
174    /// `RUST_LOG`, `OTEL_EXPORTER_OTLP_ENDPOINT`, or `CUDA_VISIBLE_DEVICES`.
175    ///
176    /// ## Example
177    ///
178    /// ```yaml
179    /// env:
180    ///   RUST_LOG: info
181    ///   OTEL_EXPORTER_OTLP_ENDPOINT: http://collector:4317
182    /// nodes:
183    ///   - id: verbose-node
184    ///     path: path/to/node
185    ///     env:
186    ///       RUST_LOG: debug  # overrides the global RUST_LOG for this node
187    /// ```
188    #[serde(default, skip_serializing_if = "Option::is_none")]
189    pub env: Option<BTreeMap<String, EnvValue>>,
190}
191
192impl Descriptor {
193    /// A dataflow of `nodes` with every dataflow-level option left at its
194    /// default (the state a YAML file with only a `nodes:` key deserializes
195    /// to).
196    ///
197    /// `Descriptor` is `#[non_exhaustive]`, so other crates cannot build one
198    /// with a struct literal. Start here and assign the options you need — the
199    /// fields are all still `pub`.
200    pub fn new(nodes: Vec<Node>) -> Self {
201        Self {
202            nodes,
203            deploy: None,
204            debug: Default::default(),
205            health_check_interval: None,
206            strict_types: None,
207            exit_when_nodes_finish: None,
208            type_rules: Default::default(),
209            env: None,
210        }
211    }
212}
213
214/// A type compatibility rule declared in the dataflow YAML.
215#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
216#[serde(deny_unknown_fields)]
217// See the note on `Node`: `type_rules:` is a dataflow-level YAML surface, so a
218// per-rule option added later (a direction flag, a coercion mode) must stay a
219// minor release. Construct with `TypeRuleDef::new`; the fields remain `pub`.
220#[non_exhaustive]
221pub struct TypeRuleDef {
222    /// Source type URN
223    pub from: String,
224    /// Target type URN
225    pub to: String,
226}
227
228impl TypeRuleDef {
229    /// A rule declaring that `from` is compatible with `to`.
230    ///
231    /// `TypeRuleDef` is `#[non_exhaustive]`, so other crates cannot build one
232    /// with a struct literal. Start here and assign any further options — the
233    /// fields are all still `pub`.
234    pub fn new(from: String, to: String) -> Self {
235        Self { from, to }
236    }
237}
238
239/// Specifies when a node should be restarted.
240#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema)]
241#[serde(rename_all = "kebab-case")]
242// The descriptor *enums* — this one, `OutputFraming`, `DistributeStrategy`,
243// `NodeSource`, `GitRepoRev`, `EnvValue`, `OperatorSource`, `PythonSourceDef`,
244// the four `Ros2*` enums and `config::QueuePolicy` — are deliberately NOT
245// `#[non_exhaustive]`, unlike the descriptor structs.
246//
247// The cost of that is real and known: adding a variant (`restart-policy:
248// unless-stopped`, a third `output_framing`, an rsync `distribute` strategy) is
249// `enum_variant_added` — a semver-major break — so it cannot land until 2.0.
250//
251// It is deliberate because the alternative is worse here. `#[non_exhaustive]`
252// forces a `_ =>` arm in every downstream match. Marking all of them stops the
253// build with 11 such matches in `dora-core` and the ROS2 bridge alone, before
254// it even reaches the ones in `dora-daemon` and `dora-cli` — restart decisions,
255// git-ref resolution, lockfile cache keys, operator-runtime dispatch, ROS2
256// transport selection. Those crates ship in lockstep with this one, so today a
257// new variant is a compile error naming every site that must handle it; behind
258// a catch-all it becomes a silent wrong answer (a new `GitRepoRev` colliding in
259// the build lockfile key, a new `RestartPolicy` reading as "never restart").
260// Exhaustive matching is the thing actually preventing those bugs, and no
261// external consumer gets a comparable guarantee back.
262//
263// The structs have no such tension: a new field breaks only struct literals,
264// which the `::new` constructors already replace.
265pub enum RestartPolicy {
266    /// Never restart the node (default)
267    #[default]
268    Never,
269    /// Restart the node if it exits with a non-zero exit code.
270    OnFailure,
271    /// Always restart the node when it exits, regardless of exit code.
272    ///
273    /// The node will not be restarted on the following conditions:
274    ///
275    /// - The node was stopped by the user (e.g., via `dora stop`).
276    /// - All inputs to the node have been closed and the node finished with a non-zero exit code.
277    Always,
278}
279
280/// Deployment configuration for distributing nodes across machines.
281#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
282#[serde(deny_unknown_fields)]
283// Same rationale as `Node`: keeps a new deployment key a minor release.
284// Every field has a meaningful default, so `Deploy::default()` is the
285// construction entry point rather than a bespoke `new`; the fields remain
286// `pub`.
287#[non_exhaustive]
288pub struct Deploy {
289    /// Target machine for deployment
290    pub machine: Option<String>,
291    /// Working directory for the deployment
292    pub working_dir: Option<PathBuf>,
293    /// Labels for label-based scheduling (e.g. `gpu: "true"`, `arch: arm64`).
294    /// The coordinator matches these against daemon labels reported at registration.
295    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
296    pub labels: BTreeMap<String, String>,
297    /// How built binaries are distributed to remote daemons.
298    #[serde(default)]
299    pub distribute: DistributeStrategy,
300}
301
302/// Strategy for distributing built binaries to daemons.
303#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
304#[serde(rename_all = "lowercase")]
305pub enum DistributeStrategy {
306    /// Each daemon builds from source (current/default behavior).
307    #[default]
308    Local,
309    /// CLI pushes built binary via SSH/SCP before spawn.
310    Scp,
311    /// Daemon pulls binary from coordinator HTTP artifact store before spawn.
312    Http,
313}
314
315/// Debug options for dataflow development and troubleshooting.
316#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
317#[serde(deny_unknown_fields)]
318// See the note on `Node`: `debug:` is a dataflow-level YAML surface and a
319// second debug option must stay a minor release. Every field has a meaningful
320// default, so `Debug::default()` is the construction entry point; the fields
321// remain `pub`.
322#[non_exhaustive]
323pub struct Debug {
324    /// When true, daemons mirror every node output to the coordinator WebSocket
325    /// so that `dora topic echo`, `dora topic hz`, and `dora topic info` can
326    /// inspect runtime messages.
327    #[serde(default)]
328    pub enable_debug_inspection: bool,
329}
330
331/// # Dora Node Configuration
332///
333/// A node represents a computational unit in a Dora dataflow. Each node runs as a
334/// separate process and can communicate with other nodes through inputs and outputs.
335#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
336#[serde(deny_unknown_fields)]
337// Adding a descriptor key must stay a *minor* release. Without this, every new
338// per-node field is `constructible_struct_adds_field` — a semver-major break
339// for `dora-message` — which turns routine feature work into a "land it before
340// the next major or wait" scramble. `Node::new` is the construction entry point
341// for other crates; the fields stay `pub`, so they are still freely readable
342// and assignable. The *wire-protocol* enums in this crate (`daemon_to_node.rs`,
343// `node_to_daemon.rs`, `daemon_to_coordinator.rs`, `daemon_to_daemon.rs`) were
344// marked for the same reason in #3151, and `RunDataflowOptions` in the daemon
345// is the existing struct-shaped precedent. The descriptor *enums* are a
346// separate axis and are not covered — see the note on `RestartPolicy`.
347//
348// The construction advice lives here and on `Node::new`, not in the `///`
349// doc: that doc is the description schemars writes into `dora-schema.json`,
350// which YAML editors show to dataflow authors, and rustdoc already flags
351// `#[non_exhaustive]` types on its own.
352#[non_exhaustive]
353pub struct Node {
354    /// Unique node identifier. Must not contain `/` characters.
355    ///
356    /// Node IDs can be arbitrary strings with the following limitations:
357    ///
358    /// - They must not contain any `/` characters (slashes).
359    /// - We do not recommend using whitespace characters (e.g. spaces) in IDs
360    ///
361    /// Each node must have an ID field.
362    ///
363    /// ## Example
364    ///
365    /// ```yaml
366    /// nodes:
367    ///   - id: camera_node
368    ///   - id: some_other_node
369    /// ```
370    pub id: NodeId,
371
372    /// Human-readable node name for documentation.
373    ///
374    /// This optional field can be used to define a more descriptive name in addition to a short
375    /// [`id`](Self::id).
376    ///
377    /// ## Example
378    ///
379    /// ```yaml
380    /// nodes:
381    ///   - id: camera_node
382    ///     name: "Camera Input Handler"
383    pub name: Option<String>,
384
385    /// Detailed description of the node's functionality.
386    ///
387    /// ## Example
388    ///
389    /// ```yaml
390    /// nodes:
391    ///   - id: camera_node
392    ///     description: "Captures video frames from webcam"
393    /// ```
394    pub description: Option<String>,
395
396    /// Path to executable or script that should be run.
397    ///
398    /// Specifies the path of the executable or script that Dora should run when starting the
399    /// dataflow.
400    /// This can point to a normal executable (e.g. when using a compiled language such as Rust) or
401    /// a Python script.
402    ///
403    /// Dora will automatically append a `.exe` extension on Windows systems when the specified
404    /// file name has no extension.
405    ///
406    /// ## Example
407    ///
408    /// ```yaml
409    /// nodes:
410    ///   - id: rust-example
411    ///     path: target/release/rust-node
412    ///   - id: python-example
413    ///     path: ./receive_data.py
414    /// ```
415    ///
416    /// ## URL as Path
417    ///
418    /// The `path` field can also point to a URL instead of a local path.
419    /// In this case, Dora will download the given file when starting the dataflow.
420    ///
421    /// Note that this is quite an old feature and using this functionality is **not recommended**
422    /// anymore. Instead, we recommend using a [`git`][Self::git] and/or [`build`](Self::build)
423    /// key.
424    #[serde(default, skip_serializing_if = "Option::is_none")]
425    pub path: Option<String>,
426
427    /// SHA-256 checksum the `path` download must match, verified after fetch
428    /// and on cache reuse (spec §8.2/§8.4). Set internally when a `hub:`
429    /// reference resolves to a prebuilt binary artifact; rarely set by hand.
430    #[serde(default, skip_serializing_if = "Option::is_none")]
431    pub path_sha256: Option<String>,
432
433    /// Command-line arguments passed to the executable.
434    ///
435    /// The command-line arguments that should be passed to the executable/script specified in `path`.
436    /// The arguments should be separated by space.
437    /// This field is optional and defaults to an empty argument list.
438    ///
439    /// ## Example
440    /// ```yaml
441    /// nodes:
442    ///   - id: example
443    ///     path: example-node
444    ///     args: -v --some-flag foo
445    /// ```
446    #[serde(default, skip_serializing_if = "Option::is_none")]
447    pub args: Option<String>,
448
449    /// Environment variables for node builds and execution.
450    ///
451    /// Key-value map of environment variables that should be set for both the
452    /// [`build`](Self::build) operation and the node execution (i.e. when the node is spawned
453    /// through [`path`](Self::path)).
454    ///
455    /// Supports strings, numbers, and booleans.
456    ///
457    /// ## Example
458    ///
459    /// ```yaml
460    /// nodes:
461    ///   - id: example-node
462    ///     path: path/to/node
463    ///     env:
464    ///       DEBUG: true
465    ///       PORT: 8080
466    ///       API_KEY: "secret-key"
467    /// ```
468    pub env: Option<BTreeMap<String, EnvValue>>,
469
470    /// Multiple operators running in a shared runtime process.
471    ///
472    /// Operators are an experimental, lightweight alternative to nodes.
473    /// Instead of running as a separate process, operators are linked into a runtime process.
474    /// This allows running multiple operators to share a single address space (not supported for
475    /// Python currently).
476    ///
477    /// Operators are defined as part of the node list, as children of a runtime node.
478    /// A runtime node is a special node that specifies no [`path`](Self::path) field, but contains
479    /// an `operators` field instead.
480    ///
481    /// ## Example
482    ///
483    /// ```yaml
484    /// nodes:
485    ///   - id: runtime-node
486    ///     operators:
487    ///       - id: processor
488    ///         python: process.py
489    /// ```
490    #[serde(default, skip_serializing_if = "Option::is_none")]
491    pub operators: Option<RuntimeNode>,
492
493    /// Single operator configuration.
494    ///
495    /// This is a convenience field for defining runtime nodes that contain only a single operator.
496    /// This field is an alternative to the [`operators`](Self::operators) field, which can be used
497    /// if there is only a single operator defined for the runtime node.
498    ///
499    /// ## Example
500    ///
501    /// ```yaml
502    /// nodes:
503    ///   - id: runtime-node
504    ///     operator:
505    ///       id: processor
506    ///       python: script.py
507    ///       outputs: [data]
508    /// ```
509    #[serde(default, skip_serializing_if = "Option::is_none")]
510    pub operator: Option<SingleOperatorDefinition>,
511
512    /// ROS2 bridge configuration (unstable).
513    ///
514    /// Declares this node as a ROS2 bridge that automatically subscribes to or
515    /// publishes on ROS2 topics. No custom code is needed -- the framework spawns
516    /// a bridge binary that converts between ROS2 DDS messages and Dora's Arrow
517    /// format.
518    ///
519    /// ## Example
520    ///
521    /// ```yaml
522    /// nodes:
523    ///   - id: camera_bridge
524    ///     ros2:
525    ///       topic: /camera/image_raw
526    ///       message_type: sensor_msgs/Image
527    ///       direction: subscribe
528    ///     outputs:
529    ///       - image
530    /// ```
531    #[serde(default, skip_serializing_if = "Option::is_none")]
532    pub ros2: Option<Ros2BridgeConfig>,
533
534    /// Output data identifiers produced by this node.
535    ///
536    /// List of output identifiers that the node sends.
537    /// Must contain all `output_id` values that the node uses when sending output, e.g. through the
538    /// [`send_output`](https://docs.rs/dora-node-api/latest/dora_node_api/struct.DoraNode.html#method.send_output)
539    /// function.
540    ///
541    /// ## Example
542    ///
543    /// ```yaml
544    /// nodes:
545    ///   - id: example-node
546    ///     outputs:
547    ///       - processed_image
548    ///       - metadata
549    /// ```
550    #[serde(default)]
551    pub outputs: BTreeSet<DataId>,
552
553    /// Optional type annotations for outputs.
554    ///
555    /// Maps output identifiers to type URNs (e.g. `std/media/v1/Image`).
556    /// Only annotated outputs are type-checked; unannotated outputs remain dynamic.
557    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
558    pub output_types: BTreeMap<DataId, String>,
559
560    /// Per-output framing overrides (default: Raw for all).
561    ///
562    /// Maps output identifiers to their wire framing mode.
563    /// Outputs not listed here use the default `Raw` framing.
564    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
565    pub output_framing: BTreeMap<DataId, OutputFraming>,
566
567    /// Input data connections from other nodes.
568    ///
569    /// Defines the inputs that this node is subscribing to.
570    ///
571    /// The `inputs` field should be a key-value map of the following format:
572    ///
573    /// `input_id: source_node_id/source_node_output_id`
574    ///
575    /// The components are defined as follows:
576    ///
577    ///   - `input_id` is the local identifier that should be used for this input.
578    ///
579    ///     This will map to the `id` field of
580    ///     [`Event::Input`](https://docs.rs/dora-node-api/latest/dora_node_api/enum.Event.html#variant.Input)
581    ///     events sent to the node event loop.
582    ///   - `source_node_id` should be the `id` field of the node that sends the output that we want
583    ///     to subscribe to
584    ///   - `source_node_output_id` should be the identifier of the output that that we want
585    ///     to subscribe to
586    ///
587    /// ## Example
588    ///
589    /// ```yaml
590    /// nodes:
591    ///   - id: example-node
592    ///     outputs:
593    ///       - one
594    ///       - two
595    ///   - id: receiver
596    ///     inputs:
597    ///         my_input: example-node/two
598    /// ```
599    #[serde(default)]
600    pub inputs: BTreeMap<DataId, Input>,
601
602    /// Optional type annotations for inputs.
603    ///
604    /// Maps input identifiers to expected type URNs. Used by `dora validate`
605    /// to check that upstream output types match expectations.
606    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
607    pub input_types: BTreeMap<DataId, String>,
608
609    /// Required metadata keys per output.
610    ///
611    /// Maps output identifiers to lists of required metadata key names.
612    /// These are checked at build/validate time.
613    ///
614    /// ## Example
615    ///
616    /// ```yaml
617    /// output_metadata:
618    ///   response: [request_id]
619    /// ```
620    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
621    pub output_metadata: BTreeMap<DataId, Vec<String>>,
622
623    /// Communication pattern shorthand (e.g. `service-server`).
624    ///
625    /// Automatically implies required metadata keys on all outputs.
626    /// See `pattern_metadata_keys()` for supported patterns.
627    #[serde(default, skip_serializing_if = "Option::is_none")]
628    pub pattern: Option<String>,
629
630    /// Redirect stdout/stderr to a data output.
631    ///
632    /// This field can be used to send all stdout and stderr output of the node as a Dora output.
633    /// Each output line is sent as a separate message.
634    ///
635    ///
636    /// ## Example
637    ///
638    /// ```yaml
639    /// nodes:
640    ///   - id: example
641    ///     send_stdout_as: stdout_output
642    ///   - id: logger
643    ///     inputs:
644    ///         example_output: example/stdout_output
645    /// ```
646    #[serde(skip_serializing_if = "Option::is_none")]
647    pub send_stdout_as: Option<String>,
648
649    /// Redirect structured log entries to a data output as JSON strings.
650    ///
651    /// Unlike `send_stdout_as` which sends raw stdout lines, this sends only
652    /// parsed structured log entries (with level, timestamp, message, fields).
653    ///
654    /// ## Example
655    ///
656    /// ```yaml
657    /// nodes:
658    ///   - id: sensor
659    ///     path: ./sensor
660    ///     send_logs_as: logs
661    ///     outputs:
662    ///       - data
663    ///       - logs
664    /// ```
665    #[serde(skip_serializing_if = "Option::is_none")]
666    pub send_logs_as: Option<String>,
667
668    /// Minimum log level for this node (error, warn, info, debug, trace, stdout).
669    ///
670    /// Logs below this level are suppressed from file output, coordinator
671    /// forwarding, and `send_logs_as` routing.
672    ///
673    /// ## Example
674    ///
675    /// ```yaml
676    /// nodes:
677    ///   - id: noisy_sensor
678    ///     path: ./sensor
679    ///     min_log_level: info
680    /// ```
681    #[serde(skip_serializing_if = "Option::is_none")]
682    pub min_log_level: Option<String>,
683
684    /// Maximum log file size before rotation (e.g. "50MB", "1GB").
685    ///
686    /// When the JSONL log file exceeds this size, it is rotated. Old files
687    /// are renamed with numeric suffixes (`.1.jsonl`, `.2.jsonl`, etc.) and
688    /// the oldest are deleted once 5 rotated files exist.
689    ///
690    /// ## Example
691    ///
692    /// ```yaml
693    /// nodes:
694    ///   - id: sensor
695    ///     path: ./sensor
696    ///     max_log_size: "100MB"
697    /// ```
698    #[serde(skip_serializing_if = "Option::is_none")]
699    pub max_log_size: Option<String>,
700    /// Maximum number of rotated log files to keep (default: 5, range: 0-100)
701    ///
702    /// `0` keeps the active log only, rotating the previous one away.
703    #[serde(default, skip_serializing_if = "Option::is_none")]
704    #[schemars(range(max = 100))]
705    pub max_rotated_files: Option<u32>,
706
707    /// Build commands executed during `dora build`. Each line runs separately.
708    ///
709    /// The `build` key specifies the command that should be invoked for building the node.
710    /// The key expects a single- or multi-line string.
711    ///
712    /// Each line is run as a separate command.
713    /// Spaces are used to separate arguments.
714    ///
715    /// Note that all the environment variables specified in the [`env`](Self::env) field are also
716    /// applied to the build commands.
717    ///
718    /// ## Special treatment of `pip`
719    ///
720    /// Build lines that start with `pip` or `pip3` are treated in a special way:
721    /// If the `--uv` argument is passed to the `dora build` command, all `pip`/`pip3` commands are
722    /// run through the [`uv` package manager](https://docs.astral.sh/uv/).
723    ///
724    /// ## Example
725    ///
726    /// ```yaml
727    /// nodes:
728    /// - id: build-example
729    ///   build: cargo build -p receive_data --release
730    ///   path: target/release/receive_data
731    /// - id: multi-line-example
732    ///   build: |
733    ///       pip install requirements.txt
734    ///       pip install -e some/local/package
735    ///   path: package
736    /// ```
737    ///
738    /// In the above example, the `pip` commands will be replaced by `uv pip` when run through
739    /// `dora build --uv`.
740    #[serde(default, skip_serializing_if = "Option::is_none")]
741    pub build: Option<String>,
742
743    /// Git repository URL for downloading nodes.
744    ///
745    /// The `git` key allows downloading nodes (i.e. their source code) from git repositories.
746    /// This can be especially useful for distributed dataflows.
747    ///
748    /// When a `git` key is specified, `dora build` automatically clones the specified repository
749    /// (or reuse an existing clone).
750    /// Then it checks out the specified [`branch`](Self::branch), [`tag`](Self::tag), or
751    /// [`rev`](Self::rev), or the default branch if none of them are specified.
752    /// Afterwards it runs the [`build`](Self::build) command if specified.
753    ///
754    /// Note that the git clone directory is set as working directory for both the
755    /// [`build`](Self::build) command and the specified [`path`](Self::path).
756    ///
757    /// ## Example
758    ///
759    /// ```yaml
760    /// nodes:
761    ///   - id: rust-node
762    ///     git: https://github.com/dora-rs/dora.git
763    ///     build: cargo build -p rust-dataflow-example-node
764    ///     path: target/debug/rust-dataflow-example-node
765    /// ```
766    ///
767    /// In the above example, `dora build` will first clone the specified `git` repository and then
768    /// run the specified `build` inside the local clone directory.
769    /// When `dora run` or `dora start` is invoked, the working directory will be the git clone
770    /// directory too. So a relative `path` will start from the clone directory.
771    #[serde(default, skip_serializing_if = "Option::is_none")]
772    pub git: Option<String>,
773
774    /// Hub package reference.
775    ///
776    /// **Outside the 1.0 stability guarantee.** This field, the way it is
777    /// resolved, and the `HubProvenance` recorded in the lockfile may change
778    /// or be removed in a minor release. `dora build` and `dora validate`
779    /// print a warning whenever a dataflow uses it.
780    ///
781    /// The reason is readiness rather than scope: stabilizing `hub:` would
782    /// promise a typed-contract guarantee that no package in the catalog
783    /// currently delivers. The path to stabilization is the node-typing
784    /// workstream, not more code here — see `docs/plan-node-hub.md` §14 (P3.5).
785    ///
786    /// References a node published in the Dora Hub index:
787    /// `[<namespace>/]<name>@<semver-requirement>`. A bare name is shorthand
788    /// for the official `dora-rs/` namespace.
789    ///
790    /// `dora build` resolves the reference against the index to a pinned
791    /// commit and the node is fetched/built through the same machinery as a
792    /// [`git`](Self::git) node; the package manifest supplies the
793    /// entrypoint, build command, and typed contracts. Mutually exclusive
794    /// with `path`, `git`, and `build`.
795    ///
796    /// ## Example
797    ///
798    /// ```yaml
799    /// nodes:
800    ///   - id: detector
801    ///     hub: dora-yolo@^0.5
802    ///     inputs:
803    ///       image: camera/image
804    ///     outputs:
805    ///       - bbox
806    /// ```
807    #[serde(default, skip_serializing_if = "Option::is_none")]
808    pub hub: Option<String>,
809
810    /// Git branch to checkout after cloning.
811    ///
812    /// The `branch` field is only allowed in combination with the [`git`](#git) field.
813    /// It specifies the branch that should be checked out after cloning.
814    /// Only one of `branch`, `tag`, or `rev` can be specified.
815    ///
816    /// ## Example
817    ///
818    /// ```yaml
819    /// nodes:
820    ///   - id: rust-node
821    ///     git: https://github.com/dora-rs/dora.git
822    ///     branch: some-branch-name
823    /// ```
824    #[serde(default, skip_serializing_if = "Option::is_none")]
825    pub branch: Option<String>,
826
827    /// Git tag to checkout after cloning.
828    ///
829    /// The `tag` field is only allowed in combination with the [`git`](#git) field.
830    /// It specifies the git tag that should be checked out after cloning.
831    /// Only one of `branch`, `tag`, or `rev` can be specified.
832    ///
833    /// ## Example
834    ///
835    /// ```yaml
836    /// nodes:
837    ///   - id: rust-node
838    ///     git: https://github.com/dora-rs/dora.git
839    ///     tag: v0.1.0
840    /// ```
841    #[serde(default, skip_serializing_if = "Option::is_none")]
842    pub tag: Option<String>,
843
844    /// Git revision (e.g. commit hash) to checkout after cloning.
845    ///
846    /// The `rev` field is only allowed in combination with the [`git`](#git) field.
847    /// It specifies the git revision (e.g. a commit hash) that should be checked out after cloning.
848    /// Only one of `branch`, `tag`, or `rev` can be specified.
849    ///
850    /// ## Example
851    ///
852    /// ```yaml
853    /// nodes:
854    ///   - id: rust-node
855    ///     git: https://github.com/dora-rs/dora.git
856    ///     rev: 64ab0d7c
857    /// ```
858    #[serde(default, skip_serializing_if = "Option::is_none")]
859    pub rev: Option<String>,
860
861    /// Whether this node should be restarted on exit or error.
862    ///
863    /// Defaults to `RestartPolicy::Never`.
864    #[serde(default)]
865    pub restart_policy: RestartPolicy,
866
867    /// Size of the zenoh shared memory pool for zero-copy output publishing.
868    ///
869    /// Accepts an integer (raw bytes) or a string with a unit suffix
870    /// (`KB`, `MB`, `GB`, case-insensitive). If unset, the
871    /// `DORA_NODE_SHM_POOL_SIZE` env var is used, falling back to a
872    /// built-in default.
873    ///
874    /// ## Example
875    ///
876    /// ```yaml
877    /// nodes:
878    ///   - id: camera-node
879    ///     shared_memory_pool_size: 128MB
880    /// ```
881    #[serde(default, skip_serializing_if = "Option::is_none")]
882    pub shared_memory_pool_size: Option<ByteSize>,
883
884    /// Maximum number of restart attempts. 0 means unlimited.
885    ///
886    /// When combined with `restart_window`, this limits restarts within the window period.
887    /// For example, `max_restarts: 5` with `restart_window: 300` means "5 restarts per 5 minutes".
888    #[serde(default)]
889    pub max_restarts: u32,
890
891    /// Initial delay in seconds before restarting. Doubles each attempt (exponential backoff).
892    ///
893    /// For example, with `restart_delay: 1.0`, delays will be 1s, 2s, 4s, 8s, ...
894    /// Use `max_restart_delay` to cap the backoff.
895    #[serde(default, skip_serializing_if = "Option::is_none")]
896    pub restart_delay: Option<f64>,
897
898    /// Maximum delay in seconds for exponential backoff.
899    ///
900    /// Caps the exponentially growing `restart_delay`. For example, with
901    /// `restart_delay: 1.0` and `max_restart_delay: 30.0`, delays grow as
902    /// 1s, 2s, 4s, 8s, 16s, 30s, 30s, ...
903    #[serde(default, skip_serializing_if = "Option::is_none")]
904    pub max_restart_delay: Option<f64>,
905
906    /// Time window in seconds for counting restarts.
907    ///
908    /// When set, the restart counter resets after this period of time elapses since the
909    /// first restart in the current window. This enables "N restarts within M seconds" semantics.
910    #[serde(default, skip_serializing_if = "Option::is_none")]
911    pub restart_window: Option<f64>,
912
913    /// Health check timeout in seconds.
914    ///
915    /// When set, the daemon monitors this node for activity **once it has
916    /// connected** (i.e. subscribed to events during `Node::init`). If the
917    /// connected node then does not communicate with the daemon within this
918    /// timeout, it is killed and the restart policy is evaluated.
919    ///
920    /// This bounds post-connection liveness only, not startup time: a node
921    /// still in a slow cold start has not connected yet and is never killed by
922    /// this watchdog. A node that hangs before it ever subscribes is therefore
923    /// not reaped here either.
924    #[serde(default, skip_serializing_if = "Option::is_none")]
925    pub health_check_timeout: Option<f64>,
926
927    /// Per-node finish-drain grace period in seconds.
928    ///
929    /// Overrides the global `DORA_FINISH_DRAIN_GRACE_SECS` for this node only.
930    /// When all other nodes in a dataflow have finished, the daemon waits this
931    /// long after the node's last input closes before force-stopping it.
932    ///
933    /// Set to a large value (e.g. `3600.0`) for nodes that need significant
934    /// post-input compute time (ML training, large-batch inference, checkpoint
935    /// writes) to prevent premature SIGKILL while the computation is in progress.
936    ///
937    /// When unset, the global grace period applies (default 120s, controlled
938    /// by `DORA_FINISH_DRAIN_GRACE_SECS`).
939    #[serde(default, skip_serializing_if = "Option::is_none")]
940    pub finish_grace_secs: Option<f64>,
941
942    /// Path to a module definition file (e.g. `nav_module.yml`).
943    ///
944    /// A module is a reusable sub-dataflow: a group of nodes with declared
945    /// inputs and outputs. At build time the module is expanded inline —
946    /// internal node IDs are prefixed with `{module_id}.` and all wiring is
947    /// rewritten so the runtime sees only flat nodes.
948    ///
949    /// A module node has no source or per-node runtime configuration of its own,
950    /// so only `module`, `inputs`, `params`, `env`, `build`, and `deploy` are
951    /// meaningful on it. Every other node field is rejected at expansion time
952    /// rather than silently discarded -- both the source/kind fields (`path`,
953    /// `args`, `path_sha256`, `git`, `hub`, `branch`, `tag`, `rev`, `operators`,
954    /// `operator`, `ros2`) and per-node runtime fields (`outputs`,
955    /// `output_types`, `cpu_affinity`, `restart_policy`, ...). The same rule
956    /// applies at every nesting level.
957    ///
958    /// `env`, `build`, `deploy`, and `params` *are* accepted: they propagate
959    /// into the module's inner nodes.
960    ///
961    /// ## Example
962    ///
963    /// ```yaml
964    /// nodes:
965    ///   - id: nav_stack
966    ///     module: modules/navigation_module.yml
967    ///     inputs:
968    ///       goal_pose: localization/goal
969    /// ```
970    #[serde(default, skip_serializing_if = "Option::is_none")]
971    pub module: Option<String>,
972
973    /// Parameters passed to a module for compile-time substitution.
974    ///
975    /// Only meaningful when `module` is set. Values are substituted into
976    /// inner node `args` fields (using `${_param.name}` syntax) and can be
977    /// injected into inner node `env` maps.
978    ///
979    /// ## Example
980    ///
981    /// ```yaml
982    /// nodes:
983    ///   - id: nav_stack
984    ///     module: modules/navigation_module.yml
985    ///     params:
986    ///       speed: "2.0"
987    ///       mode: turbo
988    /// ```
989    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
990    pub params: BTreeMap<String, String>,
991
992    /// CPU cores to pin this node's process to (Linux only, ignored on other platforms).
993    ///
994    /// ## Example
995    ///
996    /// ```yaml
997    /// nodes:
998    ///   - id: fast_node
999    ///     path: ./fast_node
1000    ///     cpu_affinity: [0, 1]
1001    /// ```
1002    #[serde(default, skip_serializing_if = "Option::is_none")]
1003    pub cpu_affinity: Option<Vec<usize>>,
1004
1005    /// Machine deployment configuration.
1006    #[schemars(skip)]
1007    pub deploy: Option<Deploy>,
1008
1009    /// Startup connection deadline in seconds.
1010    ///
1011    /// If the node process fails to connect (subscribe to events) within this
1012    /// many seconds after process spawn, the daemon kills the process and
1013    /// evaluates the `restart_policy`.
1014    ///
1015    /// Evaluated on each `health_check_interval` tick (default 5s).
1016    #[serde(default, skip_serializing_if = "Option::is_none")]
1017    pub startup_timeout: Option<f64>,
1018}
1019
1020impl Node {
1021    /// A node with the given ID and every other field left at its descriptor
1022    /// default (the state a YAML node with only an `id:` key deserializes to).
1023    ///
1024    /// `Node` is `#[non_exhaustive]`, so other crates cannot build one with a
1025    /// struct literal. Start here and assign the fields you need — they are all
1026    /// still `pub`:
1027    ///
1028    /// ```
1029    /// use dora_message::descriptor::Node;
1030    ///
1031    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
1032    /// let mut node = Node::new("camera".parse()?);
1033    /// node.path = Some("./camera".to_owned());
1034    /// # Ok(())
1035    /// # }
1036    /// ```
1037    pub fn new(id: NodeId) -> Self {
1038        Self {
1039            id,
1040            name: None,
1041            description: None,
1042            path: None,
1043            path_sha256: None,
1044            args: None,
1045            env: None,
1046            operators: None,
1047            operator: None,
1048            ros2: None,
1049            outputs: Default::default(),
1050            output_types: Default::default(),
1051            output_framing: Default::default(),
1052            inputs: Default::default(),
1053            input_types: Default::default(),
1054            shared_memory_pool_size: None,
1055            output_metadata: Default::default(),
1056            pattern: None,
1057            send_stdout_as: None,
1058            send_logs_as: None,
1059            min_log_level: None,
1060            max_log_size: None,
1061            max_rotated_files: None,
1062            build: None,
1063            git: None,
1064            hub: None,
1065            branch: None,
1066            tag: None,
1067            rev: None,
1068            restart_policy: Default::default(),
1069            max_restarts: 0,
1070            restart_delay: None,
1071            max_restart_delay: None,
1072            restart_window: None,
1073            health_check_timeout: None,
1074            finish_grace_secs: None,
1075            module: None,
1076            params: Default::default(),
1077            cpu_affinity: None,
1078            deploy: None,
1079            startup_timeout: None,
1080        }
1081    }
1082}
1083
1084/// A [`Node`] after alias resolution and defaulting, as the daemon runs it.
1085#[allow(missing_docs)]
1086#[derive(Debug, Clone, Serialize, Deserialize)]
1087// Same rationale as `Node`: keeps a new per-node key a minor release. This is
1088// where keys that are not custom-node-specific land — `cpu_affinity` and
1089// `deploy` are threaded `Node` -> `ResolvedNode` without passing through
1090// `CustomNode`. Construct with `ResolvedNode::new`; the fields remain `pub`.
1091// Resolution itself goes through `ResolvedNode::from_node`, the in-crate
1092// literal that keeps a new field a compile error.
1093#[non_exhaustive]
1094pub struct ResolvedNode {
1095    pub id: NodeId,
1096    pub name: Option<String>,
1097    pub description: Option<String>,
1098    pub env: Option<BTreeMap<String, EnvValue>>,
1099
1100    #[serde(default)]
1101    pub cpu_affinity: Option<Vec<usize>>,
1102
1103    #[serde(default)]
1104    pub deploy: Option<Deploy>,
1105
1106    #[serde(flatten)]
1107    pub kind: CoreNodeKind,
1108}
1109
1110#[allow(missing_docs)]
1111impl ResolvedNode {
1112    /// A resolved node with the given ID and kind, and every other field left
1113    /// at its default.
1114    ///
1115    /// `ResolvedNode` is `#[non_exhaustive]`, so other crates cannot build one
1116    /// with a struct literal. Start here and assign the fields you need — they
1117    /// are all still `pub`.
1118    pub fn new(id: NodeId, kind: CoreNodeKind) -> Self {
1119        Self {
1120            id,
1121            name: None,
1122            description: None,
1123            env: None,
1124            cpu_affinity: None,
1125            deploy: None,
1126            kind,
1127        }
1128    }
1129
1130    /// The resolved node for `node`'s node-level keys — `id`, `name`,
1131    /// `description`, `env`, `cpu_affinity`, `deploy` — around an already
1132    /// resolved `kind`. `env` is carried as declared; merging the
1133    /// dataflow-level `env` into it is the caller's job.
1134    ///
1135    /// The kind-level keys are dropped: for a custom node
1136    /// [`CustomNode::from_node`] has already moved them out, and a runtime
1137    /// node's live in its `operators`.
1138    ///
1139    /// This is a struct literal on purpose. `ResolvedNode` is
1140    /// `#[non_exhaustive]`, so a literal only compiles here, inside the
1141    /// defining crate — exactly where the compiler still insists that every
1142    /// field is accounted for. A field added to `ResolvedNode` is a build
1143    /// error on this line, not a key that resolution silently leaves at its
1144    /// default.
1145    pub fn from_node(node: Node, kind: CoreNodeKind) -> Self {
1146        Self {
1147            id: node.id,
1148            name: node.name,
1149            description: node.description,
1150            env: node.env,
1151            cpu_affinity: node.cpu_affinity,
1152            deploy: node.deploy,
1153            kind,
1154        }
1155    }
1156
1157    pub fn has_git_source(&self) -> bool {
1158        self.kind
1159            .as_custom()
1160            .map(|n| n.source.is_git())
1161            .unwrap_or_default()
1162    }
1163}
1164
1165#[allow(missing_docs)]
1166#[derive(Debug, Clone, Serialize, Deserialize)]
1167#[serde(rename_all = "lowercase")]
1168#[allow(clippy::large_enum_variant)]
1169pub enum CoreNodeKind {
1170    /// Dora runtime node
1171    #[serde(rename = "operators")]
1172    Runtime(RuntimeNode),
1173    Custom(CustomNode),
1174}
1175
1176#[allow(missing_docs)]
1177impl CoreNodeKind {
1178    pub fn as_custom(&self) -> Option<&CustomNode> {
1179        match self {
1180            CoreNodeKind::Runtime(_) => None,
1181            CoreNodeKind::Custom(custom_node) => Some(custom_node),
1182        }
1183    }
1184}
1185
1186#[allow(missing_docs)]
1187#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1188#[serde(transparent)]
1189pub struct RuntimeNode {
1190    /// List of operators running in this runtime
1191    pub operators: Vec<OperatorDefinition>,
1192}
1193
1194#[allow(missing_docs)]
1195#[derive(Debug, Serialize, Deserialize, JsonSchema, Clone)]
1196pub struct OperatorDefinition {
1197    /// Unique operator identifier within the runtime
1198    pub id: OperatorId,
1199    #[serde(flatten)]
1200    pub config: OperatorConfig,
1201}
1202
1203#[allow(missing_docs)]
1204#[derive(Debug, Serialize, Deserialize, JsonSchema, Clone)]
1205// Same rationale as `Node`: keeps a new operator-level descriptor key a minor
1206// release. No constructor yet because nothing constructs one outside this crate
1207// — every instance comes from deserialization. Adding a constructor later is
1208// itself a minor change, so only the `#[non_exhaustive]` half is time-critical;
1209// open an issue if you need to build one programmatically.
1210#[non_exhaustive]
1211pub struct SingleOperatorDefinition {
1212    /// Operator identifier (optional for single operators)
1213    pub id: Option<OperatorId>,
1214    #[serde(flatten)]
1215    pub config: OperatorConfig,
1216}
1217
1218#[allow(missing_docs)]
1219#[derive(Debug, Serialize, Deserialize, JsonSchema, Clone)]
1220// Same rationale as `Node`: keeps a new operator-level descriptor key a minor
1221// release. No constructor yet because nothing constructs one outside this crate
1222// — every instance comes from deserialization. Adding a constructor later is
1223// itself a minor change, so only the `#[non_exhaustive]` half is time-critical;
1224// open an issue if you need to build one programmatically.
1225//
1226// Kept as a `//` comment deliberately: a `///` doc here is `#[serde(flatten)]`ed
1227// by schemars onto `OperatorDefinition`'s entry in `dora-schema.json`, which
1228// YAML editors show to dataflow authors.
1229#[non_exhaustive]
1230pub struct OperatorConfig {
1231    /// Human-readable operator name
1232    pub name: Option<String>,
1233    /// Detailed description of the operator
1234    pub description: Option<String>,
1235
1236    /// Input data connections
1237    #[serde(default)]
1238    pub inputs: BTreeMap<DataId, Input>,
1239    /// Output data identifiers
1240    #[serde(default)]
1241    pub outputs: BTreeSet<DataId>,
1242    /// Optional type annotations for outputs
1243    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1244    pub output_types: BTreeMap<DataId, String>,
1245
1246    /// Per-output framing overrides (default: Raw for all).
1247    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1248    pub output_framing: BTreeMap<DataId, OutputFraming>,
1249
1250    /// Optional type annotations for inputs
1251    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1252    pub input_types: BTreeMap<DataId, String>,
1253
1254    /// Required metadata keys per output
1255    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1256    pub output_metadata: BTreeMap<DataId, Vec<String>>,
1257
1258    /// Communication pattern shorthand (e.g. `service-server`)
1259    #[serde(default, skip_serializing_if = "Option::is_none")]
1260    pub pattern: Option<String>,
1261
1262    /// Operator source configuration (Python, shared library, etc.)
1263    #[serde(flatten)]
1264    pub source: OperatorSource,
1265
1266    /// Build commands for this operator
1267    #[serde(default, skip_serializing_if = "Option::is_none")]
1268    pub build: Option<String>,
1269    /// Redirect stdout to data output
1270    #[serde(skip_serializing_if = "Option::is_none")]
1271    pub send_stdout_as: Option<String>,
1272    /// Redirect structured log entries to a data output as JSON strings
1273    #[serde(skip_serializing_if = "Option::is_none")]
1274    pub send_logs_as: Option<String>,
1275    /// Minimum log level for this operator
1276    #[serde(skip_serializing_if = "Option::is_none")]
1277    pub min_log_level: Option<String>,
1278    /// Maximum log file size before rotation (e.g. "50MB", "1GB")
1279    #[serde(skip_serializing_if = "Option::is_none")]
1280    pub max_log_size: Option<String>,
1281    /// Maximum number of rotated log files to keep (default: 5, range: 0-100)
1282    ///
1283    /// `0` keeps the active log only, rotating the previous one away.
1284    #[serde(default, skip_serializing_if = "Option::is_none")]
1285    #[schemars(range(max = 100))]
1286    pub max_rotated_files: Option<u32>,
1287}
1288
1289#[allow(missing_docs)]
1290#[derive(Debug, Serialize, Deserialize, JsonSchema, Clone)]
1291#[serde(rename_all = "kebab-case")]
1292pub enum OperatorSource {
1293    SharedLibrary(String),
1294    Python(PythonSource),
1295    #[schemars(skip)]
1296    Wasm(String),
1297}
1298#[allow(missing_docs)]
1299#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1300#[serde(from = "PythonSourceDef", into = "PythonSourceDef")]
1301pub struct PythonSource {
1302    pub source: String,
1303    pub conda_env: Option<String>,
1304}
1305
1306#[allow(missing_docs)]
1307#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1308#[serde(untagged)]
1309pub enum PythonSourceDef {
1310    SourceOnly(String),
1311    WithOptions {
1312        source: String,
1313        conda_env: Option<String>,
1314    },
1315}
1316
1317impl From<PythonSource> for PythonSourceDef {
1318    fn from(input: PythonSource) -> Self {
1319        match input {
1320            PythonSource {
1321                source,
1322                conda_env: None,
1323            } => Self::SourceOnly(source),
1324            PythonSource { source, conda_env } => Self::WithOptions { source, conda_env },
1325        }
1326    }
1327}
1328
1329impl From<PythonSourceDef> for PythonSource {
1330    fn from(value: PythonSourceDef) -> Self {
1331        match value {
1332            PythonSourceDef::SourceOnly(source) => Self {
1333                source,
1334                conda_env: None,
1335            },
1336            PythonSourceDef::WithOptions { source, conda_env } => Self { source, conda_env },
1337        }
1338    }
1339}
1340
1341/// Built-in runtime name for shared-library operators.
1342pub const RUNTIME_SHARED_LIBRARY: &str = "shared-library";
1343/// Built-in runtime name for Python operators.
1344pub const RUNTIME_PYTHON: &str = "python";
1345/// Built-in runtime name for WebAssembly operators.
1346pub const RUNTIME_WASM: &str = "wasm";
1347
1348impl OperatorSource {
1349    /// The name of the runtime that hosts operators declared with this source.
1350    ///
1351    /// This mapping is the single source of truth for "which runtime hosts this
1352    /// operator": the daemon's spawn logic and the CLI's build hashing key on
1353    /// the name rather than matching each variant.
1354    pub fn runtime_name(&self) -> &'static str {
1355        match self {
1356            OperatorSource::SharedLibrary(_) => RUNTIME_SHARED_LIBRARY,
1357            OperatorSource::Python(_) => RUNTIME_PYTHON,
1358            OperatorSource::Wasm(_) => RUNTIME_WASM,
1359        }
1360    }
1361}
1362
1363#[allow(missing_docs)]
1364#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1365// See the note on `Node`: keeps a new resolved-node field a minor release.
1366// Construct with `CustomNode::new`; the fields remain `pub`. Resolution goes
1367// through `CustomNode::from_node`, the in-crate literal that keeps a new field
1368// a compile error rather than a silently dropped descriptor key.
1369#[non_exhaustive]
1370pub struct CustomNode {
1371    /// Path of the source code
1372    ///
1373    /// If you want to use a specific `conda` environment.
1374    /// Provide the python path within the source.
1375    ///
1376    /// source: /home/peter/miniconda3/bin/python
1377    ///
1378    /// args: some_node.py
1379    ///
1380    /// Source can match any executable in PATH.
1381    pub path: String,
1382    pub source: NodeSource,
1383    /// SHA-256 the `path` download must match (set for hub binary artifacts,
1384    /// spec §8.2). When present the daemon fetches `path` as a verified URL
1385    /// download regardless of confinement — the checksum is the trust anchor.
1386    #[serde(default, skip_serializing_if = "Option::is_none")]
1387    pub path_sha256: Option<String>,
1388    /// Args for the executable.
1389    #[serde(default, skip_serializing_if = "Option::is_none")]
1390    pub args: Option<String>,
1391    /// Environment variables injected during resolution.
1392    ///
1393    /// Not user-writable: [`Node::env`] is the YAML surface. Resolution folds
1394    /// it into this field, and the ROS2 bridge desugaring uses it to pass
1395    /// `DORA_ROS2_BRIDGE_CONFIG` to the spawned bridge binary.
1396    pub envs: Option<BTreeMap<String, EnvValue>>,
1397    #[serde(default, skip_serializing_if = "Option::is_none")]
1398    pub build: Option<String>,
1399    /// Send stdout and stderr to another node
1400    #[serde(skip_serializing_if = "Option::is_none")]
1401    pub send_stdout_as: Option<String>,
1402    /// Redirect structured log entries to a data output as JSON strings
1403    #[serde(skip_serializing_if = "Option::is_none")]
1404    pub send_logs_as: Option<String>,
1405    /// Minimum log level for this node
1406    #[serde(skip_serializing_if = "Option::is_none")]
1407    pub min_log_level: Option<String>,
1408    /// Maximum log file size before rotation (e.g. "50MB", "1GB")
1409    #[serde(skip_serializing_if = "Option::is_none")]
1410    pub max_log_size: Option<String>,
1411    /// Maximum number of rotated log files to keep (default: 5, range: 0-100)
1412    ///
1413    /// `0` keeps the active log only, rotating the previous one away.
1414    #[serde(default, skip_serializing_if = "Option::is_none")]
1415    #[schemars(range(max = 100))]
1416    pub max_rotated_files: Option<u32>,
1417
1418    #[serde(default)]
1419    pub restart_policy: RestartPolicy,
1420
1421    /// Maximum number of restart attempts. 0 means unlimited.
1422    #[serde(default)]
1423    pub max_restarts: u32,
1424
1425    /// Initial delay in seconds before restarting (exponential backoff).
1426    #[serde(default, skip_serializing_if = "Option::is_none")]
1427    pub restart_delay: Option<f64>,
1428
1429    /// Maximum delay in seconds for exponential backoff.
1430    #[serde(default, skip_serializing_if = "Option::is_none")]
1431    pub max_restart_delay: Option<f64>,
1432
1433    /// Time window in seconds for counting restarts.
1434    #[serde(default, skip_serializing_if = "Option::is_none")]
1435    pub restart_window: Option<f64>,
1436
1437    /// Health check timeout in seconds.
1438    ///
1439    /// When set, the daemon monitors this node for activity **once it has
1440    /// connected** (i.e. subscribed to events during `Node::init`). If the
1441    /// connected node then does not communicate with the daemon within this
1442    /// timeout, it is killed and the restart policy is evaluated.
1443    ///
1444    /// This bounds post-connection liveness only, not startup time: a node
1445    /// still in a slow cold start has not connected yet and is never killed by
1446    /// this watchdog. A node that hangs before it ever subscribes is therefore
1447    /// not reaped here either.
1448    #[serde(default, skip_serializing_if = "Option::is_none")]
1449    pub health_check_timeout: Option<f64>,
1450
1451    /// Per-node finish-drain grace period in seconds.
1452    ///
1453    /// Overrides the global `DORA_FINISH_DRAIN_GRACE_SECS` for this node only.
1454    #[serde(default, skip_serializing_if = "Option::is_none")]
1455    pub finish_grace_secs: Option<f64>,
1456
1457    #[serde(flatten)]
1458    pub run_config: NodeRunConfig,
1459
1460    /// Startup connection deadline in seconds.
1461    ///
1462    /// If the node process fails to connect (subscribe to events) within this
1463    /// many seconds after process spawn, the daemon kills the process and
1464    /// evaluates the `restart_policy`.
1465    ///
1466    /// Evaluated on each `health_check_interval` tick (default 5s).
1467    // Appended last so existing fields keep their wire-format order.
1468    #[serde(default, skip_serializing_if = "Option::is_none")]
1469    pub startup_timeout: Option<f64>,
1470}
1471
1472impl CustomNode {
1473    /// A local-source node at `path` with every other field left at its
1474    /// default.
1475    ///
1476    /// `CustomNode` is `#[non_exhaustive]`, so other crates cannot build one
1477    /// with a struct literal. Start here and assign the fields you need — they
1478    /// are all still `pub`.
1479    pub fn new(path: String) -> Self {
1480        Self {
1481            path,
1482            source: NodeSource::Local,
1483            path_sha256: None,
1484            args: None,
1485            envs: None,
1486            build: None,
1487            send_stdout_as: None,
1488            send_logs_as: None,
1489            min_log_level: None,
1490            max_log_size: None,
1491            max_rotated_files: None,
1492            restart_policy: Default::default(),
1493            max_restarts: 0,
1494            restart_delay: None,
1495            max_restart_delay: None,
1496            restart_window: None,
1497            health_check_timeout: None,
1498            finish_grace_secs: None,
1499            run_config: NodeRunConfig::default(),
1500            startup_timeout: None,
1501        }
1502    }
1503
1504    /// Move the custom-node keys out of `node` into a `CustomNode` at `path`.
1505    ///
1506    /// Every key that all custom-node kinds resolve identically is taken from
1507    /// `node`, leaving its `Option`s empty and its collections cleared. What
1508    /// stays behind are the node-level keys — `id`, `name`, `description`,
1509    /// `env`, `cpu_affinity`, `deploy` — for [`ResolvedNode::from_node`] to
1510    /// consume next, plus the kind-selection keys (`git`, `hub`, `operators`,
1511    /// `ros2`, …) that classification has already read.
1512    ///
1513    /// `source` and `envs` stay at their defaults: they are the two keys whose
1514    /// value depends on the node kind, so the caller sets them — `source` from
1515    /// classification for a `path:` node, `envs` for the ROS2 bridge, whose
1516    /// `path` is its fixed binary.
1517    ///
1518    /// This is a struct literal on purpose. `CustomNode` and `NodeRunConfig`
1519    /// are `#[non_exhaustive]`, so a literal only compiles here, inside the
1520    /// defining crate — exactly where the compiler still insists that every
1521    /// field is accounted for. A key added to either struct is a build error
1522    /// on this line, not a descriptor key that parses and is then silently
1523    /// dropped. `dora-core`'s `every_custom_node_field_is_carried_through`
1524    /// checks the values on top.
1525    pub fn from_node(node: &mut Node, path: String) -> Self {
1526        Self {
1527            path,
1528            source: NodeSource::Local,
1529            path_sha256: node.path_sha256.take(),
1530            args: node.args.take(),
1531            envs: None,
1532            build: node.build.take(),
1533            send_stdout_as: node.send_stdout_as.take(),
1534            send_logs_as: node.send_logs_as.take(),
1535            min_log_level: node.min_log_level.take(),
1536            max_log_size: node.max_log_size.take(),
1537            max_rotated_files: node.max_rotated_files.take(),
1538            restart_policy: node.restart_policy,
1539            max_restarts: node.max_restarts,
1540            restart_delay: node.restart_delay.take(),
1541            max_restart_delay: node.max_restart_delay.take(),
1542            restart_window: node.restart_window.take(),
1543            health_check_timeout: node.health_check_timeout.take(),
1544            finish_grace_secs: node.finish_grace_secs.take(),
1545            run_config: NodeRunConfig {
1546                inputs: std::mem::take(&mut node.inputs),
1547                outputs: std::mem::take(&mut node.outputs),
1548                output_types: std::mem::take(&mut node.output_types),
1549                output_framing: std::mem::take(&mut node.output_framing),
1550                input_types: std::mem::take(&mut node.input_types),
1551                shared_memory_pool_size: node.shared_memory_pool_size.take(),
1552            },
1553            startup_timeout: node.startup_timeout.take(),
1554        }
1555    }
1556}
1557
1558#[allow(missing_docs)]
1559#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1560pub enum NodeSource {
1561    Local,
1562    GitBranch {
1563        repo: String,
1564        rev: Option<GitRepoRev>,
1565    },
1566}
1567
1568#[allow(missing_docs)]
1569impl NodeSource {
1570    pub fn is_git(&self) -> bool {
1571        matches!(self, Self::GitBranch { .. })
1572    }
1573}
1574
1575#[allow(missing_docs)]
1576#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1577pub enum GitRepoRev {
1578    Branch(String),
1579    Tag(String),
1580    Rev(String),
1581}
1582
1583#[allow(missing_docs)]
1584#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1585#[serde(untagged)]
1586pub enum EnvValue {
1587    #[serde(deserialize_with = "with_expand_envs")]
1588    Bool(bool),
1589    #[serde(deserialize_with = "with_expand_envs")]
1590    Integer(i64),
1591    #[serde(deserialize_with = "with_expand_envs")]
1592    Float(f64),
1593    #[serde(deserialize_with = "with_expand_envs")]
1594    String(String),
1595}
1596
1597impl fmt::Display for EnvValue {
1598    fn fmt(&self, fmt: &mut fmt::Formatter) -> fmt::Result {
1599        match self {
1600            EnvValue::Bool(bool) => fmt.write_str(&bool.to_string()),
1601            EnvValue::Integer(i64) => fmt.write_str(&i64.to_string()),
1602            EnvValue::Float(f64) => fmt.write_str(&f64.to_string()),
1603            EnvValue::String(str) => fmt.write_str(str),
1604        }
1605    }
1606}
1607
1608/// ROS2 bridge configuration for declarative ROS2 bridging.
1609///
1610/// This allows nodes to interact with ROS2 topics, services, and actions
1611/// without writing any custom code. The framework spawns a bridge binary that
1612/// handles the ROS2 DDS communication and Arrow data conversion.
1613///
1614/// Exactly one of `topic`, `topics`, `service`, or `action` must be set.
1615#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1616#[serde(deny_unknown_fields)]
1617pub struct Ros2BridgeConfig {
1618    /// Native transport used to communicate with the ROS2 graph.
1619    ///
1620    /// Defaults to the existing DDS implementation.
1621    #[serde(default)]
1622    pub transport: Ros2TransportConfig,
1623
1624    /// ROS2 topic name (e.g. "/camera/image_raw").
1625    /// Mutually exclusive with `topics`, `service`, `action`.
1626    #[serde(default, skip_serializing_if = "Option::is_none")]
1627    pub topic: Option<String>,
1628
1629    /// ROS2 message type (e.g. "sensor_msgs/Image").
1630    /// Required when `topic` is set.
1631    #[serde(default, skip_serializing_if = "Option::is_none")]
1632    pub message_type: Option<String>,
1633
1634    /// Direction: subscribe (ROS2 -> Dora) or publish (Dora -> ROS2).
1635    /// Defaults to subscribe. Only used with `topic`/`topics`.
1636    #[serde(default)]
1637    pub direction: Ros2Direction,
1638
1639    /// Multiple topics on a single ROS2 node context.
1640    /// Mutually exclusive with `topic`, `service`, `action`.
1641    #[serde(default, skip_serializing_if = "Option::is_none")]
1642    pub topics: Option<Vec<Ros2TopicConfig>>,
1643
1644    /// ROS2 service name (e.g. "/add_two_ints").
1645    /// Mutually exclusive with `topic`, `topics`, `action`.
1646    #[serde(default, skip_serializing_if = "Option::is_none")]
1647    pub service: Option<String>,
1648
1649    /// ROS2 service type (e.g. "example_interfaces/AddTwoInts").
1650    /// Required when `service` is set.
1651    #[serde(default, skip_serializing_if = "Option::is_none")]
1652    pub service_type: Option<String>,
1653
1654    /// ROS2 action name (e.g. "/navigate").
1655    /// Mutually exclusive with `topic`, `topics`, `service`.
1656    #[serde(default, skip_serializing_if = "Option::is_none")]
1657    pub action: Option<String>,
1658
1659    /// ROS2 action type (e.g. "nav2_msgs/NavigateToPose").
1660    /// Required when `action` is set.
1661    #[serde(default, skip_serializing_if = "Option::is_none")]
1662    pub action_type: Option<String>,
1663
1664    /// Role: client or server. Required for `service` and `action`.
1665    #[serde(default, skip_serializing_if = "Option::is_none")]
1666    pub role: Option<Ros2Role>,
1667
1668    /// QoS policies applied to all topics (can be overridden per-topic).
1669    #[serde(default)]
1670    pub qos: Ros2QosConfig,
1671
1672    /// ROS2 namespace (default: "/").
1673    #[serde(default = "default_ros2_namespace")]
1674    pub namespace: String,
1675
1676    /// ROS2 node name. Defaults to the dora node id.
1677    #[serde(default, skip_serializing_if = "Option::is_none")]
1678    pub node_name: Option<String>,
1679}
1680
1681impl Default for Ros2BridgeConfig {
1682    fn default() -> Self {
1683        Self {
1684            transport: Ros2TransportConfig::default(),
1685            topic: None,
1686            message_type: None,
1687            direction: Ros2Direction::default(),
1688            topics: None,
1689            service: None,
1690            service_type: None,
1691            action: None,
1692            action_type: None,
1693            role: None,
1694            qos: Ros2QosConfig::default(),
1695            namespace: default_ros2_namespace(),
1696            node_name: None,
1697        }
1698    }
1699}
1700
1701/// Native transport used by a ROS2 bridge context.
1702#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
1703#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
1704pub enum Ros2TransportConfig {
1705    /// The existing `ros2-client` and RustDDS transport.
1706    #[default]
1707    Dds,
1708    /// Direct interoperability with `rmw_zenoh_cpp` peers.
1709    Zenoh {
1710        /// Wire-compatibility profile used by the target ROS2 distribution.
1711        compatibility: RmwZenohCompatibility,
1712        /// Optional Zenoh session configuration path.
1713        #[serde(default, skip_serializing_if = "Option::is_none")]
1714        config_uri: Option<PathBuf>,
1715    },
1716}
1717
1718/// Wire-compatibility profile for the `rmw_zenoh_cpp` protocol.
1719#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1720#[serde(rename_all = "snake_case")]
1721pub enum RmwZenohCompatibility {
1722    /// ROS2 Humble, whose endpoint identity uses `TypeHashNotSupported`.
1723    Humble,
1724    /// ROS2 distributions whose endpoint identity uses REP-2016 type hashes.
1725    Rep2016,
1726}
1727
1728/// Role of a ROS2 service or action bridge node.
1729#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1730#[serde(rename_all = "snake_case")]
1731pub enum Ros2Role {
1732    /// Client: sends requests/goals, receives responses/results.
1733    Client,
1734    /// Server: receives requests, sends responses.
1735    Server,
1736}
1737
1738fn default_ros2_namespace() -> String {
1739    "/".to_string()
1740}
1741
1742/// Configuration for a single ROS2 topic in multi-topic mode.
1743#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1744#[serde(deny_unknown_fields)]
1745pub struct Ros2TopicConfig {
1746    /// ROS2 topic name.
1747    pub topic: String,
1748
1749    /// ROS2 message type (e.g. "geometry_msgs/Twist").
1750    pub message_type: String,
1751
1752    /// Direction: subscribe or publish.
1753    #[serde(default)]
1754    pub direction: Ros2Direction,
1755
1756    /// Maps to an dora output id (for subscribe direction).
1757    #[serde(default, skip_serializing_if = "Option::is_none")]
1758    pub output: Option<String>,
1759
1760    /// Maps to an dora input id (for publish direction).
1761    #[serde(default, skip_serializing_if = "Option::is_none")]
1762    pub input: Option<String>,
1763
1764    /// Per-topic QoS override.
1765    #[serde(default, skip_serializing_if = "Option::is_none")]
1766    pub qos: Option<Ros2QosConfig>,
1767}
1768
1769/// Strip the leading `/` of a ROS2 topic name, then map any remaining `/` to
1770/// `_` (e.g. `/robot/scan` -> `robot_scan`).
1771///
1772/// ```
1773/// use dora_message::descriptor::derive_port_id;
1774///
1775/// assert_eq!(derive_port_id("/robot/scan"), "robot_scan");
1776/// assert_eq!(derive_port_id("scan"), "scan");
1777/// ```
1778pub fn derive_port_id(topic: &str) -> String {
1779    topic.trim_start_matches('/').replace('/', "_")
1780}
1781
1782impl Ros2TopicConfig {
1783    /// The dora output a `subscribe` topic feeds: the explicit `output:`
1784    /// mapping, or the topic-derived id when that is unset.
1785    ///
1786    /// Both the descriptor validator (`dora-core`) and the ros2 bridge node
1787    /// depend on producing the *same* id — the validator rejects configs whose
1788    /// port is not declared precisely because the bridge would otherwise
1789    /// silently drop the data — so the rule lives here in the shared message
1790    /// crate rather than being duplicated at each call site.
1791    pub fn output_port_id(&self) -> String {
1792        self.output
1793            .clone()
1794            .unwrap_or_else(|| derive_port_id(&self.topic))
1795    }
1796
1797    /// The dora input a `publish` topic consumes; the counterpart of
1798    /// [`Self::output_port_id`].
1799    pub fn input_port_id(&self) -> String {
1800        self.input
1801            .clone()
1802            .unwrap_or_else(|| derive_port_id(&self.topic))
1803    }
1804}
1805
1806/// The dora port a **single-topic** (`topic:`) ros2 bridge binds to, or `None`
1807/// when `declared_ports` leaves the choice ambiguous.
1808///
1809/// Single-topic mode has no explicit `output:`/`input:` field, so the port comes
1810/// from the node's declaration — `docs/ros2-bridge.md`: "In single-topic mode,
1811/// the node's declared `outputs` or `inputs` are used directly". Preferring the
1812/// topic-derived id when it *is* declared keeps a node that declares an extra
1813/// port (a `send_stdout_as` output, say) resolvable instead of ambiguous.
1814///
1815/// Both the descriptor resolver, which bakes the resolved id into the bridge
1816/// config, and the validator, which rejects the ambiguous case, call this — a
1817/// wrong choice here binds the bridge to a port nothing is wired to and drops
1818/// every message silently.
1819///
1820/// ```
1821/// use dora_message::descriptor::single_topic_port_id;
1822///
1823/// // The topic-derived id is preferred when the node declares it...
1824/// assert_eq!(
1825///     single_topic_port_id("/robot/scan", &["robot_scan", "status"]),
1826///     Some("robot_scan".to_string()),
1827/// );
1828/// // ...otherwise a node that declares a single port resolves to that port...
1829/// assert_eq!(
1830///     single_topic_port_id("/robot/scan", &["lidar"]),
1831///     Some("lidar".to_string()),
1832/// );
1833/// // ...but several non-matching ports leave the choice ambiguous.
1834/// assert_eq!(single_topic_port_id("/robot/scan", &["a", "b"]), None);
1835/// ```
1836pub fn single_topic_port_id(topic: &str, declared_ports: &[&str]) -> Option<String> {
1837    let derived = derive_port_id(topic);
1838    if declared_ports.contains(&derived.as_str()) {
1839        return Some(derived);
1840    }
1841    match declared_ports {
1842        [sole_port] => Some((*sole_port).to_owned()),
1843        _ => None,
1844    }
1845}
1846
1847/// Direction of ROS2 bridge communication.
1848#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
1849#[serde(rename_all = "snake_case")]
1850pub enum Ros2Direction {
1851    /// Subscribe: receive from ROS2, forward to dora outputs.
1852    #[default]
1853    Subscribe,
1854    /// Publish: receive from dora inputs, publish to ROS2.
1855    Publish,
1856}
1857
1858/// ROS2 Quality of Service configuration.
1859#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
1860#[serde(deny_unknown_fields)]
1861pub struct Ros2QosConfig {
1862    /// Use reliable transport (default: false = best effort).
1863    #[serde(default)]
1864    pub reliable: bool,
1865
1866    /// Durability: "volatile" (default), "transient_local".
1867    #[serde(default, skip_serializing_if = "Option::is_none")]
1868    pub durability: Option<String>,
1869
1870    /// Liveliness: "automatic" (default), "manual_by_participant", "manual_by_topic".
1871    #[serde(default, skip_serializing_if = "Option::is_none")]
1872    pub liveliness: Option<String>,
1873
1874    /// Lease duration in seconds (default: infinity).
1875    #[serde(default, skip_serializing_if = "Option::is_none")]
1876    pub lease_duration: Option<f64>,
1877
1878    /// Max blocking time in seconds for reliable transport.
1879    #[serde(default, skip_serializing_if = "Option::is_none")]
1880    pub max_blocking_time: Option<f64>,
1881
1882    /// History depth for KeepLast policy (default: 1).
1883    #[serde(default, skip_serializing_if = "Option::is_none")]
1884    pub keep_last: Option<i32>,
1885
1886    /// Use KeepAll history policy instead of KeepLast.
1887    #[serde(default)]
1888    pub keep_all: bool,
1889}
1890
1891#[cfg(test)]
1892mod tests {
1893    use super::*;
1894
1895    /// Assert that `constructed` serializes to exactly what `yaml`
1896    /// deserializes to — that a hand-written constructor agrees with the
1897    /// per-field `#[serde(default)]`s.
1898    ///
1899    /// The two are independent sources of the same defaults, and both are
1900    /// used in production: the daemon builds dynamically registered nodes
1901    /// through `Node::new` (`Daemon::handle_add_node`) and declared nodes
1902    /// through serde, so a divergence — say a field that later grows a
1903    /// `#[serde(default = "…")]` custom default — would silently give the two
1904    /// kinds different configuration.
1905    fn assert_matches_yaml_defaults<T: Serialize + serde::de::DeserializeOwned>(
1906        yaml: &str,
1907        constructed: T,
1908        what: &str,
1909    ) {
1910        let from_yaml: T = serde_yaml::from_str(yaml).unwrap();
1911        assert_eq!(
1912            serde_yaml::to_value(&from_yaml).unwrap(),
1913            serde_yaml::to_value(&constructed).unwrap(),
1914            "`{what}` drifted from the defaults serde applies"
1915        );
1916    }
1917
1918    /// `Node::new` must agree with what serde produces for a YAML node that
1919    /// sets nothing but `id`.
1920    #[test]
1921    fn node_new_matches_yaml_defaults() {
1922        assert_matches_yaml_defaults(
1923            "id: some-node\n",
1924            Node::new("some-node".to_owned().into()),
1925            "Node::new",
1926        );
1927    }
1928
1929    /// The same contract for the other constructors this crate hands out.
1930    ///
1931    /// `Descriptor::new` is what the coordinator's `AddNode` resolution and the
1932    /// daemon's bench support build from; `Deploy::default()` and
1933    /// `Debug::default()` are the documented entry points for `deploy:` and
1934    /// `debug:`; `NodeRunConfig::default()` is the runtime node's I/O config in
1935    /// the daemon while custom nodes deserialize theirs.
1936    ///
1937    /// `CustomNode::new` is not covered here: `source` and `envs` have no serde
1938    /// default, so there is no "nothing set" YAML for it. Its guard is
1939    /// `CustomNode::from_node` — a struct literal in this crate, so a new field
1940    /// is a compile error — plus
1941    /// `dora_core::descriptor::tests::every_custom_node_field_is_carried_through`
1942    /// for the values.
1943    #[test]
1944    fn constructors_match_yaml_defaults() {
1945        assert_matches_yaml_defaults(
1946            "nodes: []\n",
1947            Descriptor::new(Vec::new()),
1948            "Descriptor::new",
1949        );
1950        assert_matches_yaml_defaults("{}\n", Deploy::default(), "Deploy::default");
1951        assert_matches_yaml_defaults("{}\n", Debug::default(), "Debug::default");
1952        assert_matches_yaml_defaults("{}\n", NodeRunConfig::default(), "NodeRunConfig::default");
1953        assert_matches_yaml_defaults(
1954            "from: a\nto: b\n",
1955            TypeRuleDef::new("a".to_owned(), "b".to_owned()),
1956            "TypeRuleDef::new",
1957        );
1958    }
1959
1960    #[test]
1961    fn ros2_transport_defaults_to_dds() {
1962        let config: Ros2BridgeConfig =
1963            serde_yaml::from_str("topic: /chatter\nmessage_type: std_msgs/String\n").unwrap();
1964        assert!(matches!(config.transport, Ros2TransportConfig::Dds));
1965    }
1966
1967    #[test]
1968    fn ros2_transport_parses_humble_zenoh() {
1969        let config: Ros2BridgeConfig = serde_yaml::from_str(
1970            "transport:\n  kind: zenoh\n  compatibility: humble\n  config_uri: /tmp/rmw.json5\n\
1971             topic: /chatter\nmessage_type: std_msgs/String\n",
1972        )
1973        .unwrap();
1974        assert_eq!(
1975            config.transport,
1976            Ros2TransportConfig::Zenoh {
1977                compatibility: RmwZenohCompatibility::Humble,
1978                config_uri: Some("/tmp/rmw.json5".into()),
1979            }
1980        );
1981    }
1982
1983    #[test]
1984    fn ros2_transport_rejects_unknown_zenoh_compatibility() {
1985        let error = serde_yaml::from_str::<Ros2BridgeConfig>(
1986            "transport:\n  kind: zenoh\n  compatibility: automatic\n\
1987             topic: /chatter\nmessage_type: std_msgs/String\n",
1988        )
1989        .unwrap_err();
1990        assert!(error.to_string().contains("unknown variant `automatic`"));
1991    }
1992
1993    #[test]
1994    fn single_topic_port_uses_the_sole_declared_port() {
1995        // `docs/ros2-bridge.md`: single-topic mode uses the declared port
1996        // directly, so `/turtle1/pose` binds to `pose` and not to the
1997        // topic-derived `turtle1_pose`.
1998        assert_eq!(
1999            single_topic_port_id("/turtle1/pose", &["pose"]),
2000            Some("pose".to_owned())
2001        );
2002    }
2003
2004    #[test]
2005    fn single_topic_port_prefers_a_declared_topic_derived_id() {
2006        // A second declared port (here a `send_stdout_as` output) would make the
2007        // choice ambiguous, but the topic-derived id is itself declared.
2008        assert_eq!(
2009            single_topic_port_id("/turtle1/pose", &["log", "turtle1_pose"]),
2010            Some("turtle1_pose".to_owned())
2011        );
2012    }
2013
2014    #[test]
2015    fn single_topic_port_is_ambiguous_with_several_unrelated_ports() {
2016        assert_eq!(
2017            single_topic_port_id("/turtle1/pose", &["pose", "log"]),
2018            None
2019        );
2020    }
2021
2022    #[test]
2023    fn single_topic_port_needs_a_declared_port() {
2024        assert_eq!(single_topic_port_id("/turtle1/pose", &[]), None);
2025    }
2026
2027    #[test]
2028    fn output_framing_defaults_to_raw() {
2029        let yaml = r#"
2030nodes:
2031  - id: test
2032    path: test.py
2033    outputs:
2034      - data
2035"#;
2036        let desc: Descriptor = serde_yaml::from_str(yaml).unwrap();
2037        assert!(desc.nodes[0].output_framing.is_empty());
2038    }
2039
2040    #[test]
2041    fn output_framing_parses_arrow_ipc() {
2042        let yaml = r#"
2043nodes:
2044  - id: test
2045    path: test.py
2046    outputs:
2047      - data
2048    output_framing:
2049      data: arrow-ipc
2050"#;
2051        let desc: Descriptor = serde_yaml::from_str(yaml).unwrap();
2052        assert_eq!(
2053            desc.nodes[0].output_framing.get::<DataId>(&"data".into()),
2054            Some(&OutputFraming::ArrowIpc)
2055        );
2056    }
2057
2058    #[test]
2059    fn cpu_affinity_parses() {
2060        let yaml = r#"
2061nodes:
2062  - id: test
2063    path: test.py
2064    cpu_affinity: [0, 2, 4]
2065"#;
2066        let desc: Descriptor = serde_yaml::from_str(yaml).unwrap();
2067        assert_eq!(desc.nodes[0].cpu_affinity, Some(vec![0, 2, 4]));
2068    }
2069
2070    #[test]
2071    fn cpu_affinity_defaults_to_none() {
2072        let yaml = r#"
2073nodes:
2074  - id: test
2075    path: test.py
2076"#;
2077        let desc: Descriptor = serde_yaml::from_str(yaml).unwrap();
2078        assert_eq!(desc.nodes[0].cpu_affinity, None);
2079    }
2080
2081    #[test]
2082    fn debug_flag_accepts_new_name() {
2083        let yaml = r#"
2084nodes:
2085  - id: test
2086    path: test.py
2087debug:
2088  enable_debug_inspection: true
2089"#;
2090        let desc: Descriptor = serde_yaml::from_str(yaml).unwrap();
2091        assert!(desc.debug.enable_debug_inspection);
2092    }
2093
2094    #[test]
2095    fn removed_unstable_key_prefix_is_rejected_not_ignored() {
2096        // `_unstable_deploy` / `_unstable_debug` lost their prefix for 1.0.
2097        // Both must *error*, never deserialize to the default: a dataflow that
2098        // still says `_unstable_deploy` would otherwise run every node on the
2099        // local daemon while looking like it pinned them to machines, and a
2100        // stale `_unstable_debug` would leave `dora topic echo` silently
2101        // empty. `deny_unknown_fields` on `Descriptor` is what makes these
2102        // diagnosable, so this test guards that attribute as much as the
2103        // rename.
2104        for (key, block) in [
2105            ("_unstable_deploy", "_unstable_deploy:\n  machine: m1\n"),
2106            (
2107                "_unstable_debug",
2108                "_unstable_debug:\n  enable_debug_inspection: true\n",
2109            ),
2110        ] {
2111            let yaml = format!("nodes:\n  - id: test\n    path: test.py\n{block}");
2112            let err = serde_yaml::from_str::<Descriptor>(&yaml)
2113                .expect_err("the pre-1.0 `_unstable_` key must be rejected");
2114            assert!(
2115                err.to_string().contains(key),
2116                "error should name `{key}`, got: {err}"
2117            );
2118        }
2119    }
2120
2121    #[test]
2122    fn debug_flag_rejects_the_removed_legacy_alias() {
2123        // The `publish_all_messages_to_zenoh` alias was removed for 1.0. It
2124        // must *error* rather than deserialize to the default: silently
2125        // ignoring it would leave debug inspection off while the dataflow
2126        // looks like it enabled it, and `dora topic echo` would return
2127        // nothing with no explanation. `deny_unknown_fields` on `Debug` is
2128        // what turns that into a diagnosable failure.
2129        let yaml = r#"
2130nodes:
2131  - id: test
2132    path: test.py
2133debug:
2134  publish_all_messages_to_zenoh: true
2135"#;
2136        let err = serde_yaml::from_str::<Descriptor>(yaml)
2137            .expect_err("removed alias must be rejected, not silently ignored");
2138        assert!(
2139            err.to_string().contains("publish_all_messages_to_zenoh"),
2140            "error should name the offending field, got: {err}"
2141        );
2142    }
2143
2144    #[test]
2145    fn operator_source_shared_library_names_its_runtime() {
2146        let cfg: OperatorConfig = serde_yaml::from_str("shared-library: build/op").unwrap();
2147        assert!(matches!(&cfg.source, OperatorSource::SharedLibrary(s) if s == "build/op"));
2148        assert_eq!(cfg.source.runtime_name(), RUNTIME_SHARED_LIBRARY);
2149        assert_eq!(cfg.source.runtime_name(), "shared-library");
2150    }
2151
2152    #[test]
2153    fn operator_source_python_source_only_names_its_runtime() {
2154        let cfg: OperatorConfig = serde_yaml::from_str("python: op.py").unwrap();
2155        assert!(matches!(&cfg.source, OperatorSource::Python(py) if py.source == "op.py"));
2156        assert_eq!(cfg.source.runtime_name(), RUNTIME_PYTHON);
2157    }
2158
2159    #[test]
2160    fn operator_source_python_with_conda_env_names_its_runtime() {
2161        let cfg: OperatorConfig =
2162            serde_yaml::from_str("python:\n  source: op.py\n  conda_env: my-env").unwrap();
2163        match &cfg.source {
2164            OperatorSource::Python(py) => {
2165                assert_eq!(py.source, "op.py");
2166                assert_eq!(py.conda_env.as_deref(), Some("my-env"));
2167            }
2168            other => panic!("expected python source, got {other:?}"),
2169        }
2170        assert_eq!(cfg.source.runtime_name(), RUNTIME_PYTHON);
2171    }
2172
2173    #[test]
2174    fn operator_source_wasm_names_its_runtime() {
2175        let cfg: OperatorConfig = serde_yaml::from_str("wasm: op.wasm").unwrap();
2176        assert!(matches!(&cfg.source, OperatorSource::Wasm(s) if s == "op.wasm"));
2177        assert_eq!(cfg.source.runtime_name(), RUNTIME_WASM);
2178    }
2179
2180    /// The runtime a node is spawned with must survive a descriptor round-trip:
2181    /// the daemon re-parses the serialized descriptor before spawning.
2182    #[test]
2183    fn operator_source_runtime_survives_a_serde_roundtrip() {
2184        for yaml in ["shared-library: build/op", "python: op.py", "wasm: op.wasm"] {
2185            let cfg: OperatorConfig = serde_yaml::from_str(yaml).unwrap();
2186            let serialized = serde_yaml::to_string(&cfg).unwrap();
2187            let reparsed: OperatorConfig = serde_yaml::from_str(&serialized).unwrap();
2188            assert_eq!(cfg.source.runtime_name(), reparsed.source.runtime_name());
2189        }
2190    }
2191}