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}