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
1010impl Node {
1011    /// A node with the given ID and every other field left at its descriptor
1012    /// default (the state a YAML node with only an `id:` key deserializes to).
1013    ///
1014    /// `Node` is `#[non_exhaustive]`, so other crates cannot build one with a
1015    /// struct literal. Start here and assign the fields you need — they are all
1016    /// still `pub`:
1017    ///
1018    /// ```
1019    /// use dora_message::descriptor::Node;
1020    ///
1021    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
1022    /// let mut node = Node::new("camera".parse()?);
1023    /// node.path = Some("./camera".to_owned());
1024    /// # Ok(())
1025    /// # }
1026    /// ```
1027    pub fn new(id: NodeId) -> Self {
1028        Self {
1029            id,
1030            name: None,
1031            description: None,
1032            path: None,
1033            path_sha256: None,
1034            args: None,
1035            env: None,
1036            operators: None,
1037            operator: None,
1038            ros2: None,
1039            outputs: Default::default(),
1040            output_types: Default::default(),
1041            output_framing: Default::default(),
1042            inputs: Default::default(),
1043            input_types: Default::default(),
1044            shared_memory_pool_size: None,
1045            output_metadata: Default::default(),
1046            pattern: None,
1047            send_stdout_as: None,
1048            send_logs_as: None,
1049            min_log_level: None,
1050            max_log_size: None,
1051            max_rotated_files: None,
1052            build: None,
1053            git: None,
1054            hub: None,
1055            branch: None,
1056            tag: None,
1057            rev: None,
1058            restart_policy: Default::default(),
1059            max_restarts: 0,
1060            restart_delay: None,
1061            max_restart_delay: None,
1062            restart_window: None,
1063            health_check_timeout: None,
1064            finish_grace_secs: None,
1065            module: None,
1066            params: Default::default(),
1067            cpu_affinity: None,
1068            deploy: None,
1069        }
1070    }
1071}
1072
1073/// A [`Node`] after alias resolution and defaulting, as the daemon runs it.
1074#[allow(missing_docs)]
1075#[derive(Debug, Clone, Serialize, Deserialize)]
1076// Same rationale as `Node`: keeps a new per-node key a minor release. This is
1077// where keys that are not custom-node-specific land — `cpu_affinity` and
1078// `deploy` are threaded `Node` -> `ResolvedNode` without passing through
1079// `CustomNode`. Construct with `ResolvedNode::new`; the fields remain `pub`.
1080// Resolution itself goes through `ResolvedNode::from_node`, the in-crate
1081// literal that keeps a new field a compile error.
1082#[non_exhaustive]
1083pub struct ResolvedNode {
1084    pub id: NodeId,
1085    pub name: Option<String>,
1086    pub description: Option<String>,
1087    pub env: Option<BTreeMap<String, EnvValue>>,
1088
1089    #[serde(default)]
1090    pub cpu_affinity: Option<Vec<usize>>,
1091
1092    #[serde(default)]
1093    pub deploy: Option<Deploy>,
1094
1095    #[serde(flatten)]
1096    pub kind: CoreNodeKind,
1097}
1098
1099#[allow(missing_docs)]
1100impl ResolvedNode {
1101    /// A resolved node with the given ID and kind, and every other field left
1102    /// at its default.
1103    ///
1104    /// `ResolvedNode` is `#[non_exhaustive]`, so other crates cannot build one
1105    /// with a struct literal. Start here and assign the fields you need — they
1106    /// are all still `pub`.
1107    pub fn new(id: NodeId, kind: CoreNodeKind) -> Self {
1108        Self {
1109            id,
1110            name: None,
1111            description: None,
1112            env: None,
1113            cpu_affinity: None,
1114            deploy: None,
1115            kind,
1116        }
1117    }
1118
1119    /// The resolved node for `node`'s node-level keys — `id`, `name`,
1120    /// `description`, `env`, `cpu_affinity`, `deploy` — around an already
1121    /// resolved `kind`. `env` is carried as declared; merging the
1122    /// dataflow-level `env` into it is the caller's job.
1123    ///
1124    /// The kind-level keys are dropped: for a custom node
1125    /// [`CustomNode::from_node`] has already moved them out, and a runtime
1126    /// node's live in its `operators`.
1127    ///
1128    /// This is a struct literal on purpose. `ResolvedNode` is
1129    /// `#[non_exhaustive]`, so a literal only compiles here, inside the
1130    /// defining crate — exactly where the compiler still insists that every
1131    /// field is accounted for. A field added to `ResolvedNode` is a build
1132    /// error on this line, not a key that resolution silently leaves at its
1133    /// default.
1134    pub fn from_node(node: Node, kind: CoreNodeKind) -> Self {
1135        Self {
1136            id: node.id,
1137            name: node.name,
1138            description: node.description,
1139            env: node.env,
1140            cpu_affinity: node.cpu_affinity,
1141            deploy: node.deploy,
1142            kind,
1143        }
1144    }
1145
1146    pub fn has_git_source(&self) -> bool {
1147        self.kind
1148            .as_custom()
1149            .map(|n| n.source.is_git())
1150            .unwrap_or_default()
1151    }
1152}
1153
1154#[allow(missing_docs)]
1155#[derive(Debug, Clone, Serialize, Deserialize)]
1156#[serde(rename_all = "lowercase")]
1157#[allow(clippy::large_enum_variant)]
1158pub enum CoreNodeKind {
1159    /// Dora runtime node
1160    #[serde(rename = "operators")]
1161    Runtime(RuntimeNode),
1162    Custom(CustomNode),
1163}
1164
1165#[allow(missing_docs)]
1166impl CoreNodeKind {
1167    pub fn as_custom(&self) -> Option<&CustomNode> {
1168        match self {
1169            CoreNodeKind::Runtime(_) => None,
1170            CoreNodeKind::Custom(custom_node) => Some(custom_node),
1171        }
1172    }
1173}
1174
1175#[allow(missing_docs)]
1176#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1177#[serde(transparent)]
1178pub struct RuntimeNode {
1179    /// List of operators running in this runtime
1180    pub operators: Vec<OperatorDefinition>,
1181}
1182
1183#[allow(missing_docs)]
1184#[derive(Debug, Serialize, Deserialize, JsonSchema, Clone)]
1185pub struct OperatorDefinition {
1186    /// Unique operator identifier within the runtime
1187    pub id: OperatorId,
1188    #[serde(flatten)]
1189    pub config: OperatorConfig,
1190}
1191
1192#[allow(missing_docs)]
1193#[derive(Debug, Serialize, Deserialize, JsonSchema, Clone)]
1194// Same rationale as `Node`: keeps a new operator-level descriptor key a minor
1195// release. No constructor yet because nothing constructs one outside this crate
1196// — every instance comes from deserialization. Adding a constructor later is
1197// itself a minor change, so only the `#[non_exhaustive]` half is time-critical;
1198// open an issue if you need to build one programmatically.
1199#[non_exhaustive]
1200pub struct SingleOperatorDefinition {
1201    /// Operator identifier (optional for single operators)
1202    pub id: Option<OperatorId>,
1203    #[serde(flatten)]
1204    pub config: OperatorConfig,
1205}
1206
1207#[allow(missing_docs)]
1208#[derive(Debug, Serialize, Deserialize, JsonSchema, Clone)]
1209// Same rationale as `Node`: keeps a new operator-level descriptor key a minor
1210// release. No constructor yet because nothing constructs one outside this crate
1211// — every instance comes from deserialization. Adding a constructor later is
1212// itself a minor change, so only the `#[non_exhaustive]` half is time-critical;
1213// open an issue if you need to build one programmatically.
1214//
1215// Kept as a `//` comment deliberately: a `///` doc here is `#[serde(flatten)]`ed
1216// by schemars onto `OperatorDefinition`'s entry in `dora-schema.json`, which
1217// YAML editors show to dataflow authors.
1218#[non_exhaustive]
1219pub struct OperatorConfig {
1220    /// Human-readable operator name
1221    pub name: Option<String>,
1222    /// Detailed description of the operator
1223    pub description: Option<String>,
1224
1225    /// Input data connections
1226    #[serde(default)]
1227    pub inputs: BTreeMap<DataId, Input>,
1228    /// Output data identifiers
1229    #[serde(default)]
1230    pub outputs: BTreeSet<DataId>,
1231    /// Optional type annotations for outputs
1232    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1233    pub output_types: BTreeMap<DataId, String>,
1234
1235    /// Per-output framing overrides (default: Raw for all).
1236    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1237    pub output_framing: BTreeMap<DataId, OutputFraming>,
1238
1239    /// Optional type annotations for inputs
1240    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1241    pub input_types: BTreeMap<DataId, String>,
1242
1243    /// Required metadata keys per output
1244    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1245    pub output_metadata: BTreeMap<DataId, Vec<String>>,
1246
1247    /// Communication pattern shorthand (e.g. `service-server`)
1248    #[serde(default, skip_serializing_if = "Option::is_none")]
1249    pub pattern: Option<String>,
1250
1251    /// Operator source configuration (Python, shared library, etc.)
1252    #[serde(flatten)]
1253    pub source: OperatorSource,
1254
1255    /// Build commands for this operator
1256    #[serde(default, skip_serializing_if = "Option::is_none")]
1257    pub build: Option<String>,
1258    /// Redirect stdout to data output
1259    #[serde(skip_serializing_if = "Option::is_none")]
1260    pub send_stdout_as: Option<String>,
1261    /// Redirect structured log entries to a data output as JSON strings
1262    #[serde(skip_serializing_if = "Option::is_none")]
1263    pub send_logs_as: Option<String>,
1264    /// Minimum log level for this operator
1265    #[serde(skip_serializing_if = "Option::is_none")]
1266    pub min_log_level: Option<String>,
1267    /// Maximum log file size before rotation (e.g. "50MB", "1GB")
1268    #[serde(skip_serializing_if = "Option::is_none")]
1269    pub max_log_size: Option<String>,
1270    /// Maximum number of rotated log files to keep (default: 5, range: 0-100)
1271    ///
1272    /// `0` keeps the active log only, rotating the previous one away.
1273    #[serde(default, skip_serializing_if = "Option::is_none")]
1274    #[schemars(range(max = 100))]
1275    pub max_rotated_files: Option<u32>,
1276}
1277
1278#[allow(missing_docs)]
1279#[derive(Debug, Serialize, Deserialize, JsonSchema, Clone)]
1280#[serde(rename_all = "kebab-case")]
1281pub enum OperatorSource {
1282    SharedLibrary(String),
1283    Python(PythonSource),
1284    #[schemars(skip)]
1285    Wasm(String),
1286}
1287#[allow(missing_docs)]
1288#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1289#[serde(from = "PythonSourceDef", into = "PythonSourceDef")]
1290pub struct PythonSource {
1291    pub source: String,
1292    pub conda_env: Option<String>,
1293}
1294
1295#[allow(missing_docs)]
1296#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1297#[serde(untagged)]
1298pub enum PythonSourceDef {
1299    SourceOnly(String),
1300    WithOptions {
1301        source: String,
1302        conda_env: Option<String>,
1303    },
1304}
1305
1306impl From<PythonSource> for PythonSourceDef {
1307    fn from(input: PythonSource) -> Self {
1308        match input {
1309            PythonSource {
1310                source,
1311                conda_env: None,
1312            } => Self::SourceOnly(source),
1313            PythonSource { source, conda_env } => Self::WithOptions { source, conda_env },
1314        }
1315    }
1316}
1317
1318impl From<PythonSourceDef> for PythonSource {
1319    fn from(value: PythonSourceDef) -> Self {
1320        match value {
1321            PythonSourceDef::SourceOnly(source) => Self {
1322                source,
1323                conda_env: None,
1324            },
1325            PythonSourceDef::WithOptions { source, conda_env } => Self { source, conda_env },
1326        }
1327    }
1328}
1329
1330/// Built-in runtime name for shared-library operators.
1331pub const RUNTIME_SHARED_LIBRARY: &str = "shared-library";
1332/// Built-in runtime name for Python operators.
1333pub const RUNTIME_PYTHON: &str = "python";
1334/// Built-in runtime name for WebAssembly operators.
1335pub const RUNTIME_WASM: &str = "wasm";
1336
1337impl OperatorSource {
1338    /// The name of the runtime that hosts operators declared with this source.
1339    ///
1340    /// This mapping is the single source of truth for "which runtime hosts this
1341    /// operator": the daemon's spawn logic and the CLI's build hashing key on
1342    /// the name rather than matching each variant.
1343    pub fn runtime_name(&self) -> &'static str {
1344        match self {
1345            OperatorSource::SharedLibrary(_) => RUNTIME_SHARED_LIBRARY,
1346            OperatorSource::Python(_) => RUNTIME_PYTHON,
1347            OperatorSource::Wasm(_) => RUNTIME_WASM,
1348        }
1349    }
1350}
1351
1352#[allow(missing_docs)]
1353#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1354// See the note on `Node`: keeps a new resolved-node field a minor release.
1355// Construct with `CustomNode::new`; the fields remain `pub`. Resolution goes
1356// through `CustomNode::from_node`, the in-crate literal that keeps a new field
1357// a compile error rather than a silently dropped descriptor key.
1358#[non_exhaustive]
1359pub struct CustomNode {
1360    /// Path of the source code
1361    ///
1362    /// If you want to use a specific `conda` environment.
1363    /// Provide the python path within the source.
1364    ///
1365    /// source: /home/peter/miniconda3/bin/python
1366    ///
1367    /// args: some_node.py
1368    ///
1369    /// Source can match any executable in PATH.
1370    pub path: String,
1371    pub source: NodeSource,
1372    /// SHA-256 the `path` download must match (set for hub binary artifacts,
1373    /// spec §8.2). When present the daemon fetches `path` as a verified URL
1374    /// download regardless of confinement — the checksum is the trust anchor.
1375    #[serde(default, skip_serializing_if = "Option::is_none")]
1376    pub path_sha256: Option<String>,
1377    /// Args for the executable.
1378    #[serde(default, skip_serializing_if = "Option::is_none")]
1379    pub args: Option<String>,
1380    /// Environment variables injected during resolution.
1381    ///
1382    /// Not user-writable: [`Node::env`] is the YAML surface. Resolution folds
1383    /// it into this field, and the ROS2 bridge desugaring uses it to pass
1384    /// `DORA_ROS2_BRIDGE_CONFIG` to the spawned bridge binary.
1385    pub envs: Option<BTreeMap<String, EnvValue>>,
1386    #[serde(default, skip_serializing_if = "Option::is_none")]
1387    pub build: Option<String>,
1388    /// Send stdout and stderr to another node
1389    #[serde(skip_serializing_if = "Option::is_none")]
1390    pub send_stdout_as: Option<String>,
1391    /// Redirect structured log entries to a data output as JSON strings
1392    #[serde(skip_serializing_if = "Option::is_none")]
1393    pub send_logs_as: Option<String>,
1394    /// Minimum log level for this node
1395    #[serde(skip_serializing_if = "Option::is_none")]
1396    pub min_log_level: Option<String>,
1397    /// Maximum log file size before rotation (e.g. "50MB", "1GB")
1398    #[serde(skip_serializing_if = "Option::is_none")]
1399    pub max_log_size: Option<String>,
1400    /// Maximum number of rotated log files to keep (default: 5, range: 0-100)
1401    ///
1402    /// `0` keeps the active log only, rotating the previous one away.
1403    #[serde(default, skip_serializing_if = "Option::is_none")]
1404    #[schemars(range(max = 100))]
1405    pub max_rotated_files: Option<u32>,
1406
1407    #[serde(default)]
1408    pub restart_policy: RestartPolicy,
1409
1410    /// Maximum number of restart attempts. 0 means unlimited.
1411    #[serde(default)]
1412    pub max_restarts: u32,
1413
1414    /// Initial delay in seconds before restarting (exponential backoff).
1415    #[serde(default, skip_serializing_if = "Option::is_none")]
1416    pub restart_delay: Option<f64>,
1417
1418    /// Maximum delay in seconds for exponential backoff.
1419    #[serde(default, skip_serializing_if = "Option::is_none")]
1420    pub max_restart_delay: Option<f64>,
1421
1422    /// Time window in seconds for counting restarts.
1423    #[serde(default, skip_serializing_if = "Option::is_none")]
1424    pub restart_window: Option<f64>,
1425
1426    /// Health check timeout in seconds.
1427    ///
1428    /// When set, the daemon monitors this node for activity **once it has
1429    /// connected** (i.e. subscribed to events during `Node::init`). If the
1430    /// connected node then does not communicate with the daemon within this
1431    /// timeout, it is killed and the restart policy is evaluated.
1432    ///
1433    /// This bounds post-connection liveness only, not startup time: a node
1434    /// still in a slow cold start has not connected yet and is never killed by
1435    /// this watchdog. A node that hangs before it ever subscribes is therefore
1436    /// not reaped here either.
1437    #[serde(default, skip_serializing_if = "Option::is_none")]
1438    pub health_check_timeout: Option<f64>,
1439
1440    /// Per-node finish-drain grace period in seconds.
1441    ///
1442    /// Overrides the global `DORA_FINISH_DRAIN_GRACE_SECS` for this node only.
1443    #[serde(default, skip_serializing_if = "Option::is_none")]
1444    pub finish_grace_secs: Option<f64>,
1445
1446    #[serde(flatten)]
1447    pub run_config: NodeRunConfig,
1448}
1449
1450impl CustomNode {
1451    /// A local-source node at `path` with every other field left at its
1452    /// default.
1453    ///
1454    /// `CustomNode` is `#[non_exhaustive]`, so other crates cannot build one
1455    /// with a struct literal. Start here and assign the fields you need — they
1456    /// are all still `pub`.
1457    pub fn new(path: String) -> Self {
1458        Self {
1459            path,
1460            source: NodeSource::Local,
1461            path_sha256: None,
1462            args: None,
1463            envs: None,
1464            build: None,
1465            send_stdout_as: None,
1466            send_logs_as: None,
1467            min_log_level: None,
1468            max_log_size: None,
1469            max_rotated_files: None,
1470            restart_policy: Default::default(),
1471            max_restarts: 0,
1472            restart_delay: None,
1473            max_restart_delay: None,
1474            restart_window: None,
1475            health_check_timeout: None,
1476            finish_grace_secs: None,
1477            run_config: NodeRunConfig::default(),
1478        }
1479    }
1480
1481    /// Move the custom-node keys out of `node` into a `CustomNode` at `path`.
1482    ///
1483    /// Every key that all custom-node kinds resolve identically is taken from
1484    /// `node`, leaving its `Option`s empty and its collections cleared. What
1485    /// stays behind are the node-level keys — `id`, `name`, `description`,
1486    /// `env`, `cpu_affinity`, `deploy` — for [`ResolvedNode::from_node`] to
1487    /// consume next, plus the kind-selection keys (`git`, `hub`, `operators`,
1488    /// `ros2`, …) that classification has already read.
1489    ///
1490    /// `source` and `envs` stay at their defaults: they are the two keys whose
1491    /// value depends on the node kind, so the caller sets them — `source` from
1492    /// classification for a `path:` node, `envs` for the ROS2 bridge, whose
1493    /// `path` is its fixed binary.
1494    ///
1495    /// This is a struct literal on purpose. `CustomNode` and `NodeRunConfig`
1496    /// are `#[non_exhaustive]`, so a literal only compiles here, inside the
1497    /// defining crate — exactly where the compiler still insists that every
1498    /// field is accounted for. A key added to either struct is a build error
1499    /// on this line, not a descriptor key that parses and is then silently
1500    /// dropped. `dora-core`'s `every_custom_node_field_is_carried_through`
1501    /// checks the values on top.
1502    pub fn from_node(node: &mut Node, path: String) -> Self {
1503        Self {
1504            path,
1505            source: NodeSource::Local,
1506            path_sha256: node.path_sha256.take(),
1507            args: node.args.take(),
1508            envs: None,
1509            build: node.build.take(),
1510            send_stdout_as: node.send_stdout_as.take(),
1511            send_logs_as: node.send_logs_as.take(),
1512            min_log_level: node.min_log_level.take(),
1513            max_log_size: node.max_log_size.take(),
1514            max_rotated_files: node.max_rotated_files.take(),
1515            restart_policy: node.restart_policy,
1516            max_restarts: node.max_restarts,
1517            restart_delay: node.restart_delay.take(),
1518            max_restart_delay: node.max_restart_delay.take(),
1519            restart_window: node.restart_window.take(),
1520            health_check_timeout: node.health_check_timeout.take(),
1521            finish_grace_secs: node.finish_grace_secs.take(),
1522            run_config: NodeRunConfig {
1523                inputs: std::mem::take(&mut node.inputs),
1524                outputs: std::mem::take(&mut node.outputs),
1525                output_types: std::mem::take(&mut node.output_types),
1526                output_framing: std::mem::take(&mut node.output_framing),
1527                input_types: std::mem::take(&mut node.input_types),
1528                shared_memory_pool_size: node.shared_memory_pool_size.take(),
1529            },
1530        }
1531    }
1532}
1533
1534#[allow(missing_docs)]
1535#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1536pub enum NodeSource {
1537    Local,
1538    GitBranch {
1539        repo: String,
1540        rev: Option<GitRepoRev>,
1541    },
1542}
1543
1544#[allow(missing_docs)]
1545impl NodeSource {
1546    pub fn is_git(&self) -> bool {
1547        matches!(self, Self::GitBranch { .. })
1548    }
1549}
1550
1551#[allow(missing_docs)]
1552#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1553pub enum GitRepoRev {
1554    Branch(String),
1555    Tag(String),
1556    Rev(String),
1557}
1558
1559#[allow(missing_docs)]
1560#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1561#[serde(untagged)]
1562pub enum EnvValue {
1563    #[serde(deserialize_with = "with_expand_envs")]
1564    Bool(bool),
1565    #[serde(deserialize_with = "with_expand_envs")]
1566    Integer(i64),
1567    #[serde(deserialize_with = "with_expand_envs")]
1568    Float(f64),
1569    #[serde(deserialize_with = "with_expand_envs")]
1570    String(String),
1571}
1572
1573impl fmt::Display for EnvValue {
1574    fn fmt(&self, fmt: &mut fmt::Formatter) -> fmt::Result {
1575        match self {
1576            EnvValue::Bool(bool) => fmt.write_str(&bool.to_string()),
1577            EnvValue::Integer(i64) => fmt.write_str(&i64.to_string()),
1578            EnvValue::Float(f64) => fmt.write_str(&f64.to_string()),
1579            EnvValue::String(str) => fmt.write_str(str),
1580        }
1581    }
1582}
1583
1584/// ROS2 bridge configuration for declarative ROS2 bridging.
1585///
1586/// This allows nodes to interact with ROS2 topics, services, and actions
1587/// without writing any custom code. The framework spawns a bridge binary that
1588/// handles the ROS2 DDS communication and Arrow data conversion.
1589///
1590/// Exactly one of `topic`, `topics`, `service`, or `action` must be set.
1591#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1592#[serde(deny_unknown_fields)]
1593pub struct Ros2BridgeConfig {
1594    /// Native transport used to communicate with the ROS2 graph.
1595    ///
1596    /// Defaults to the existing DDS implementation.
1597    #[serde(default)]
1598    pub transport: Ros2TransportConfig,
1599
1600    /// ROS2 topic name (e.g. "/camera/image_raw").
1601    /// Mutually exclusive with `topics`, `service`, `action`.
1602    #[serde(default, skip_serializing_if = "Option::is_none")]
1603    pub topic: Option<String>,
1604
1605    /// ROS2 message type (e.g. "sensor_msgs/Image").
1606    /// Required when `topic` is set.
1607    #[serde(default, skip_serializing_if = "Option::is_none")]
1608    pub message_type: Option<String>,
1609
1610    /// Direction: subscribe (ROS2 -> Dora) or publish (Dora -> ROS2).
1611    /// Defaults to subscribe. Only used with `topic`/`topics`.
1612    #[serde(default)]
1613    pub direction: Ros2Direction,
1614
1615    /// Multiple topics on a single ROS2 node context.
1616    /// Mutually exclusive with `topic`, `service`, `action`.
1617    #[serde(default, skip_serializing_if = "Option::is_none")]
1618    pub topics: Option<Vec<Ros2TopicConfig>>,
1619
1620    /// ROS2 service name (e.g. "/add_two_ints").
1621    /// Mutually exclusive with `topic`, `topics`, `action`.
1622    #[serde(default, skip_serializing_if = "Option::is_none")]
1623    pub service: Option<String>,
1624
1625    /// ROS2 service type (e.g. "example_interfaces/AddTwoInts").
1626    /// Required when `service` is set.
1627    #[serde(default, skip_serializing_if = "Option::is_none")]
1628    pub service_type: Option<String>,
1629
1630    /// ROS2 action name (e.g. "/navigate").
1631    /// Mutually exclusive with `topic`, `topics`, `service`.
1632    #[serde(default, skip_serializing_if = "Option::is_none")]
1633    pub action: Option<String>,
1634
1635    /// ROS2 action type (e.g. "nav2_msgs/NavigateToPose").
1636    /// Required when `action` is set.
1637    #[serde(default, skip_serializing_if = "Option::is_none")]
1638    pub action_type: Option<String>,
1639
1640    /// Role: client or server. Required for `service` and `action`.
1641    #[serde(default, skip_serializing_if = "Option::is_none")]
1642    pub role: Option<Ros2Role>,
1643
1644    /// QoS policies applied to all topics (can be overridden per-topic).
1645    #[serde(default)]
1646    pub qos: Ros2QosConfig,
1647
1648    /// ROS2 namespace (default: "/").
1649    #[serde(default = "default_ros2_namespace")]
1650    pub namespace: String,
1651
1652    /// ROS2 node name. Defaults to the dora node id.
1653    #[serde(default, skip_serializing_if = "Option::is_none")]
1654    pub node_name: Option<String>,
1655}
1656
1657impl Default for Ros2BridgeConfig {
1658    fn default() -> Self {
1659        Self {
1660            transport: Ros2TransportConfig::default(),
1661            topic: None,
1662            message_type: None,
1663            direction: Ros2Direction::default(),
1664            topics: None,
1665            service: None,
1666            service_type: None,
1667            action: None,
1668            action_type: None,
1669            role: None,
1670            qos: Ros2QosConfig::default(),
1671            namespace: default_ros2_namespace(),
1672            node_name: None,
1673        }
1674    }
1675}
1676
1677/// Native transport used by a ROS2 bridge context.
1678#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
1679#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
1680pub enum Ros2TransportConfig {
1681    /// The existing `ros2-client` and RustDDS transport.
1682    #[default]
1683    Dds,
1684    /// Direct interoperability with `rmw_zenoh_cpp` peers.
1685    Zenoh {
1686        /// Wire-compatibility profile used by the target ROS2 distribution.
1687        compatibility: RmwZenohCompatibility,
1688        /// Optional Zenoh session configuration path.
1689        #[serde(default, skip_serializing_if = "Option::is_none")]
1690        config_uri: Option<PathBuf>,
1691    },
1692}
1693
1694/// Wire-compatibility profile for the `rmw_zenoh_cpp` protocol.
1695#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1696#[serde(rename_all = "snake_case")]
1697pub enum RmwZenohCompatibility {
1698    /// ROS2 Humble, whose endpoint identity uses `TypeHashNotSupported`.
1699    Humble,
1700    /// ROS2 distributions whose endpoint identity uses REP-2016 type hashes.
1701    Rep2016,
1702}
1703
1704/// Role of a ROS2 service or action bridge node.
1705#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1706#[serde(rename_all = "snake_case")]
1707pub enum Ros2Role {
1708    /// Client: sends requests/goals, receives responses/results.
1709    Client,
1710    /// Server: receives requests, sends responses.
1711    Server,
1712}
1713
1714fn default_ros2_namespace() -> String {
1715    "/".to_string()
1716}
1717
1718/// Configuration for a single ROS2 topic in multi-topic mode.
1719#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1720#[serde(deny_unknown_fields)]
1721pub struct Ros2TopicConfig {
1722    /// ROS2 topic name.
1723    pub topic: String,
1724
1725    /// ROS2 message type (e.g. "geometry_msgs/Twist").
1726    pub message_type: String,
1727
1728    /// Direction: subscribe or publish.
1729    #[serde(default)]
1730    pub direction: Ros2Direction,
1731
1732    /// Maps to an dora output id (for subscribe direction).
1733    #[serde(default, skip_serializing_if = "Option::is_none")]
1734    pub output: Option<String>,
1735
1736    /// Maps to an dora input id (for publish direction).
1737    #[serde(default, skip_serializing_if = "Option::is_none")]
1738    pub input: Option<String>,
1739
1740    /// Per-topic QoS override.
1741    #[serde(default, skip_serializing_if = "Option::is_none")]
1742    pub qos: Option<Ros2QosConfig>,
1743}
1744
1745/// Direction of ROS2 bridge communication.
1746#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
1747#[serde(rename_all = "snake_case")]
1748pub enum Ros2Direction {
1749    /// Subscribe: receive from ROS2, forward to dora outputs.
1750    #[default]
1751    Subscribe,
1752    /// Publish: receive from dora inputs, publish to ROS2.
1753    Publish,
1754}
1755
1756/// ROS2 Quality of Service configuration.
1757#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
1758#[serde(deny_unknown_fields)]
1759pub struct Ros2QosConfig {
1760    /// Use reliable transport (default: false = best effort).
1761    #[serde(default)]
1762    pub reliable: bool,
1763
1764    /// Durability: "volatile" (default), "transient_local".
1765    #[serde(default, skip_serializing_if = "Option::is_none")]
1766    pub durability: Option<String>,
1767
1768    /// Liveliness: "automatic" (default), "manual_by_participant", "manual_by_topic".
1769    #[serde(default, skip_serializing_if = "Option::is_none")]
1770    pub liveliness: Option<String>,
1771
1772    /// Lease duration in seconds (default: infinity).
1773    #[serde(default, skip_serializing_if = "Option::is_none")]
1774    pub lease_duration: Option<f64>,
1775
1776    /// Max blocking time in seconds for reliable transport.
1777    #[serde(default, skip_serializing_if = "Option::is_none")]
1778    pub max_blocking_time: Option<f64>,
1779
1780    /// History depth for KeepLast policy (default: 1).
1781    #[serde(default, skip_serializing_if = "Option::is_none")]
1782    pub keep_last: Option<i32>,
1783
1784    /// Use KeepAll history policy instead of KeepLast.
1785    #[serde(default)]
1786    pub keep_all: bool,
1787}
1788
1789#[cfg(test)]
1790mod tests {
1791    use super::*;
1792
1793    /// Assert that `constructed` serializes to exactly what `yaml`
1794    /// deserializes to — that a hand-written constructor agrees with the
1795    /// per-field `#[serde(default)]`s.
1796    ///
1797    /// The two are independent sources of the same defaults, and both are
1798    /// used in production: the daemon builds dynamically registered nodes
1799    /// through `Node::new` (`Daemon::handle_add_node`) and declared nodes
1800    /// through serde, so a divergence — say a field that later grows a
1801    /// `#[serde(default = "…")]` custom default — would silently give the two
1802    /// kinds different configuration.
1803    fn assert_matches_yaml_defaults<T: Serialize + serde::de::DeserializeOwned>(
1804        yaml: &str,
1805        constructed: T,
1806        what: &str,
1807    ) {
1808        let from_yaml: T = serde_yaml::from_str(yaml).unwrap();
1809        assert_eq!(
1810            serde_yaml::to_value(&from_yaml).unwrap(),
1811            serde_yaml::to_value(&constructed).unwrap(),
1812            "`{what}` drifted from the defaults serde applies"
1813        );
1814    }
1815
1816    /// `Node::new` must agree with what serde produces for a YAML node that
1817    /// sets nothing but `id`.
1818    #[test]
1819    fn node_new_matches_yaml_defaults() {
1820        assert_matches_yaml_defaults(
1821            "id: some-node\n",
1822            Node::new("some-node".to_owned().into()),
1823            "Node::new",
1824        );
1825    }
1826
1827    /// The same contract for the other constructors this crate hands out.
1828    ///
1829    /// `Descriptor::new` is what the coordinator's `AddNode` resolution and the
1830    /// daemon's bench support build from; `Deploy::default()` and
1831    /// `Debug::default()` are the documented entry points for `deploy:` and
1832    /// `debug:`; `NodeRunConfig::default()` is the runtime node's I/O config in
1833    /// the daemon while custom nodes deserialize theirs.
1834    ///
1835    /// `CustomNode::new` is not covered here: `source` and `envs` have no serde
1836    /// default, so there is no "nothing set" YAML for it. Its guard is
1837    /// `CustomNode::from_node` — a struct literal in this crate, so a new field
1838    /// is a compile error — plus
1839    /// `dora_core::descriptor::tests::every_custom_node_field_is_carried_through`
1840    /// for the values.
1841    #[test]
1842    fn constructors_match_yaml_defaults() {
1843        assert_matches_yaml_defaults(
1844            "nodes: []\n",
1845            Descriptor::new(Vec::new()),
1846            "Descriptor::new",
1847        );
1848        assert_matches_yaml_defaults("{}\n", Deploy::default(), "Deploy::default");
1849        assert_matches_yaml_defaults("{}\n", Debug::default(), "Debug::default");
1850        assert_matches_yaml_defaults("{}\n", NodeRunConfig::default(), "NodeRunConfig::default");
1851        assert_matches_yaml_defaults(
1852            "from: a\nto: b\n",
1853            TypeRuleDef::new("a".to_owned(), "b".to_owned()),
1854            "TypeRuleDef::new",
1855        );
1856    }
1857
1858    #[test]
1859    fn ros2_transport_defaults_to_dds() {
1860        let config: Ros2BridgeConfig =
1861            serde_yaml::from_str("topic: /chatter\nmessage_type: std_msgs/String\n").unwrap();
1862        assert!(matches!(config.transport, Ros2TransportConfig::Dds));
1863    }
1864
1865    #[test]
1866    fn ros2_transport_parses_humble_zenoh() {
1867        let config: Ros2BridgeConfig = serde_yaml::from_str(
1868            "transport:\n  kind: zenoh\n  compatibility: humble\n  config_uri: /tmp/rmw.json5\n\
1869             topic: /chatter\nmessage_type: std_msgs/String\n",
1870        )
1871        .unwrap();
1872        assert_eq!(
1873            config.transport,
1874            Ros2TransportConfig::Zenoh {
1875                compatibility: RmwZenohCompatibility::Humble,
1876                config_uri: Some("/tmp/rmw.json5".into()),
1877            }
1878        );
1879    }
1880
1881    #[test]
1882    fn ros2_transport_rejects_unknown_zenoh_compatibility() {
1883        let error = serde_yaml::from_str::<Ros2BridgeConfig>(
1884            "transport:\n  kind: zenoh\n  compatibility: automatic\n\
1885             topic: /chatter\nmessage_type: std_msgs/String\n",
1886        )
1887        .unwrap_err();
1888        assert!(error.to_string().contains("unknown variant `automatic`"));
1889    }
1890
1891    #[test]
1892    fn output_framing_defaults_to_raw() {
1893        let yaml = r#"
1894nodes:
1895  - id: test
1896    path: test.py
1897    outputs:
1898      - data
1899"#;
1900        let desc: Descriptor = serde_yaml::from_str(yaml).unwrap();
1901        assert!(desc.nodes[0].output_framing.is_empty());
1902    }
1903
1904    #[test]
1905    fn output_framing_parses_arrow_ipc() {
1906        let yaml = r#"
1907nodes:
1908  - id: test
1909    path: test.py
1910    outputs:
1911      - data
1912    output_framing:
1913      data: arrow-ipc
1914"#;
1915        let desc: Descriptor = serde_yaml::from_str(yaml).unwrap();
1916        assert_eq!(
1917            desc.nodes[0].output_framing.get::<DataId>(&"data".into()),
1918            Some(&OutputFraming::ArrowIpc)
1919        );
1920    }
1921
1922    #[test]
1923    fn cpu_affinity_parses() {
1924        let yaml = r#"
1925nodes:
1926  - id: test
1927    path: test.py
1928    cpu_affinity: [0, 2, 4]
1929"#;
1930        let desc: Descriptor = serde_yaml::from_str(yaml).unwrap();
1931        assert_eq!(desc.nodes[0].cpu_affinity, Some(vec![0, 2, 4]));
1932    }
1933
1934    #[test]
1935    fn cpu_affinity_defaults_to_none() {
1936        let yaml = r#"
1937nodes:
1938  - id: test
1939    path: test.py
1940"#;
1941        let desc: Descriptor = serde_yaml::from_str(yaml).unwrap();
1942        assert_eq!(desc.nodes[0].cpu_affinity, None);
1943    }
1944
1945    #[test]
1946    fn debug_flag_accepts_new_name() {
1947        let yaml = r#"
1948nodes:
1949  - id: test
1950    path: test.py
1951debug:
1952  enable_debug_inspection: true
1953"#;
1954        let desc: Descriptor = serde_yaml::from_str(yaml).unwrap();
1955        assert!(desc.debug.enable_debug_inspection);
1956    }
1957
1958    #[test]
1959    fn removed_unstable_key_prefix_is_rejected_not_ignored() {
1960        // `_unstable_deploy` / `_unstable_debug` lost their prefix for 1.0.
1961        // Both must *error*, never deserialize to the default: a dataflow that
1962        // still says `_unstable_deploy` would otherwise run every node on the
1963        // local daemon while looking like it pinned them to machines, and a
1964        // stale `_unstable_debug` would leave `dora topic echo` silently
1965        // empty. `deny_unknown_fields` on `Descriptor` is what makes these
1966        // diagnosable, so this test guards that attribute as much as the
1967        // rename.
1968        for (key, block) in [
1969            ("_unstable_deploy", "_unstable_deploy:\n  machine: m1\n"),
1970            (
1971                "_unstable_debug",
1972                "_unstable_debug:\n  enable_debug_inspection: true\n",
1973            ),
1974        ] {
1975            let yaml = format!("nodes:\n  - id: test\n    path: test.py\n{block}");
1976            let err = serde_yaml::from_str::<Descriptor>(&yaml)
1977                .expect_err("the pre-1.0 `_unstable_` key must be rejected");
1978            assert!(
1979                err.to_string().contains(key),
1980                "error should name `{key}`, got: {err}"
1981            );
1982        }
1983    }
1984
1985    #[test]
1986    fn debug_flag_rejects_the_removed_legacy_alias() {
1987        // The `publish_all_messages_to_zenoh` alias was removed for 1.0. It
1988        // must *error* rather than deserialize to the default: silently
1989        // ignoring it would leave debug inspection off while the dataflow
1990        // looks like it enabled it, and `dora topic echo` would return
1991        // nothing with no explanation. `deny_unknown_fields` on `Debug` is
1992        // what turns that into a diagnosable failure.
1993        let yaml = r#"
1994nodes:
1995  - id: test
1996    path: test.py
1997debug:
1998  publish_all_messages_to_zenoh: true
1999"#;
2000        let err = serde_yaml::from_str::<Descriptor>(yaml)
2001            .expect_err("removed alias must be rejected, not silently ignored");
2002        assert!(
2003            err.to_string().contains("publish_all_messages_to_zenoh"),
2004            "error should name the offending field, got: {err}"
2005        );
2006    }
2007
2008    #[test]
2009    fn operator_source_shared_library_names_its_runtime() {
2010        let cfg: OperatorConfig = serde_yaml::from_str("shared-library: build/op").unwrap();
2011        assert!(matches!(&cfg.source, OperatorSource::SharedLibrary(s) if s == "build/op"));
2012        assert_eq!(cfg.source.runtime_name(), RUNTIME_SHARED_LIBRARY);
2013        assert_eq!(cfg.source.runtime_name(), "shared-library");
2014    }
2015
2016    #[test]
2017    fn operator_source_python_source_only_names_its_runtime() {
2018        let cfg: OperatorConfig = serde_yaml::from_str("python: op.py").unwrap();
2019        assert!(matches!(&cfg.source, OperatorSource::Python(py) if py.source == "op.py"));
2020        assert_eq!(cfg.source.runtime_name(), RUNTIME_PYTHON);
2021    }
2022
2023    #[test]
2024    fn operator_source_python_with_conda_env_names_its_runtime() {
2025        let cfg: OperatorConfig =
2026            serde_yaml::from_str("python:\n  source: op.py\n  conda_env: my-env").unwrap();
2027        match &cfg.source {
2028            OperatorSource::Python(py) => {
2029                assert_eq!(py.source, "op.py");
2030                assert_eq!(py.conda_env.as_deref(), Some("my-env"));
2031            }
2032            other => panic!("expected python source, got {other:?}"),
2033        }
2034        assert_eq!(cfg.source.runtime_name(), RUNTIME_PYTHON);
2035    }
2036
2037    #[test]
2038    fn operator_source_wasm_names_its_runtime() {
2039        let cfg: OperatorConfig = serde_yaml::from_str("wasm: op.wasm").unwrap();
2040        assert!(matches!(&cfg.source, OperatorSource::Wasm(s) if s == "op.wasm"));
2041        assert_eq!(cfg.source.runtime_name(), RUNTIME_WASM);
2042    }
2043
2044    /// The runtime a node is spawned with must survive a descriptor round-trip:
2045    /// the daemon re-parses the serialized descriptor before spawning.
2046    #[test]
2047    fn operator_source_runtime_survives_a_serde_roundtrip() {
2048        for yaml in ["shared-library: build/op", "python: op.py", "wasm: op.wasm"] {
2049            let cfg: OperatorConfig = serde_yaml::from_str(yaml).unwrap();
2050            let serialized = serde_yaml::to_string(&cfg).unwrap();
2051            let reparsed: OperatorConfig = serde_yaml::from_str(&serialized).unwrap();
2052            assert_eq!(cfg.source.runtime_name(), reparsed.source.runtime_name());
2053        }
2054    }
2055}