Skip to main content

layover_core/
route.rs

1//! Edges in the route map.
2//!
3//! An edge is a *permission*, not a step in a pipeline: it says agent A may send to agent B, and
4//! nothing about when or whether it will. Rendezvous joins are the one qualification, and they
5//! constrain the receiving node rather than the sender — see [`crate::barrier`].
6
7use serde::{Deserialize, Serialize};
8
9use crate::agent::AgentName;
10
11/// The condition under which a rendezvous barrier releases.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
13#[serde(rename_all = "lowercase")]
14pub enum Join {
15    /// Wait for every declared upstream.
16    All,
17    /// Release as soon as any one upstream arrives.
18    Any,
19}
20
21/// Delivery semantics for an edge.
22///
23/// v0.1 has a single mode. Blocking request/response was superseded by rendezvous joins, which
24/// park flights instead of parking processes. The field exists so that a configuration written
25/// against the older design fails with a clear message rather than an unknown-field error.
26#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
27#[serde(rename_all = "snake_case")]
28pub enum Mode {
29    /// Fire-and-forget within the current itinerary. The sender continues immediately.
30    #[default]
31    Async,
32    /// Opens a *new* itinerary rather than continuing this one.
33    ///
34    /// The receiver gets its own Fuel, its own hop budget and its own workspace. That is what
35    /// makes per-item work affordable: a scanner dispatching one reviewer per pull request over
36    /// an `async` edge would put every reviewer on one budget, so the sweep would stop partway
37    /// and which pull requests got reviewed would be arbitrary.
38    ///
39    /// It is a route rather than a free-standing capability because the route map is the single
40    /// source of truth for who may reach whom. A spawn that skipped it would be an unchecked
41    /// edge — and an agent able to open a fresh, fully funded chain into any peer is a larger
42    /// hole than one able to send it a message.
43    Spawn,
44}
45
46impl Mode {
47    /// Returns `true` when this edge opens a new itinerary.
48    #[must_use]
49    pub fn is_spawn(self) -> bool {
50        matches!(self, Self::Spawn)
51    }
52}
53
54/// A directed edge, or set of edges, in the route map.
55#[derive(Debug, Clone, Deserialize)]
56#[serde(deny_unknown_fields)]
57pub struct Route {
58    /// Sending agents. Accepts a bare string or a list.
59    #[serde(deserialize_with = "one_or_many")]
60    pub from: Vec<AgentName>,
61    /// Receiving agents. Accepts a bare string or a list.
62    #[serde(deserialize_with = "one_or_many")]
63    pub to: Vec<AgentName>,
64    /// Delivery semantics.
65    #[serde(default)]
66    pub mode: Mode,
67    /// Rendezvous condition. When set, flights are parked until it is met.
68    #[serde(default)]
69    pub join: Option<Join>,
70    /// Backstop for an unreachable barrier.
71    #[serde(default)]
72    pub timeout_sec: Option<u64>,
73}
74
75impl Route {
76    /// Returns `true` when this edge opens a new itinerary per flight.
77    #[must_use]
78    pub fn is_spawn(&self) -> bool {
79        self.mode.is_spawn()
80    }
81
82    /// Returns `true` when this edge parks flights at a barrier.
83    #[must_use]
84    pub fn is_join(&self) -> bool {
85        self.join.is_some()
86    }
87}
88
89/// Accepts either `from = "a"` or `from = ["a", "b"]`.
90fn one_or_many<'de, D>(deserializer: D) -> Result<Vec<AgentName>, D::Error>
91where
92    D: serde::Deserializer<'de>,
93{
94    #[derive(Deserialize)]
95    #[serde(untagged)]
96    enum Raw {
97        One(AgentName),
98        Many(Vec<AgentName>),
99    }
100
101    Ok(match Raw::deserialize(deserializer)? {
102        Raw::One(name) => vec![name],
103        Raw::Many(names) => names,
104    })
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110
111    fn route(body: &str) -> Route {
112        toml::from_str(body).expect("route parses")
113    }
114
115    #[test]
116    fn from_and_to_accept_a_string_or_a_list() {
117        let route = route(
118            r#"
119            from = "planner"
120            to = ["probe_a", "probe_b"]
121            "#,
122        );
123
124        assert_eq!(route.from, vec![AgentName::from("planner")]);
125        assert_eq!(route.to.len(), 2);
126        assert!(!route.is_join());
127    }
128
129    #[test]
130    fn a_join_is_recognised_with_its_timeout() {
131        let route = route(
132            r#"
133            from = ["probe_a", "probe_b"]
134            to = "collector"
135            join = "all"
136            timeout_sec = 60
137            "#,
138        );
139
140        assert!(route.is_join());
141        assert_eq!(route.join, Some(Join::All));
142        assert_eq!(route.timeout_sec, Some(60));
143    }
144
145    #[test]
146    fn the_only_supported_mode_is_async() {
147        let route = route(
148            r#"
149            from = "a"
150            to = "b"
151            mode = "async"
152            "#,
153        );
154
155        assert_eq!(route.mode, Mode::Async);
156    }
157
158    #[test]
159    fn a_spawn_edge_is_recognised() {
160        let route = route(
161            r#"
162            from = "scanner"
163            to = "reviewer"
164            mode = "spawn"
165            "#,
166        );
167
168        assert!(route.is_spawn());
169        assert_eq!(route.mode, Mode::Spawn);
170    }
171
172    #[test]
173    fn a_deferred_mode_is_rejected_with_a_clear_error() {
174        let error = toml::from_str::<Route>(
175            r#"
176            from = "a"
177            to = "b"
178            mode = "request_response"
179            "#,
180        )
181        .expect_err("request_response was superseded by rendezvous joins");
182
183        assert!(error.to_string().contains("request_response"));
184    }
185}