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}