Skip to main content

phoxal_runtime_contract/
launch.rs

1use std::path::PathBuf;
2
3use serde::{Deserialize, Serialize};
4
5use crate::{ExecutionId, ExecutionOrigin, ProducerId};
6
7/// Default bounded shutdown grace, in milliseconds.
8pub const DEFAULT_SHUTDOWN_GRACE_MS: u64 = 2000;
9
10/// Participant launch environment variable names.
11pub mod env {
12    pub const PARTICIPANT_ID: &str = "PHOXAL_PARTICIPANT_ID";
13    pub const EXECUTION_ID: &str = "PHOXAL_EXECUTION_ID";
14    pub const PRODUCER_ID: &str = "PHOXAL_PRODUCER_ID";
15    pub const EXECUTION_ORIGIN: &str = "PHOXAL_EXECUTION_ORIGIN";
16    pub const ROBOT_ID: &str = "PHOXAL_ROBOT_ID";
17    pub const NAMESPACE: &str = "PHOXAL_NAMESPACE";
18    pub const BUNDLE_ROOT: &str = "PHOXAL_BUNDLE_ROOT";
19    pub const COMPONENT_INSTANCE: &str = "PHOXAL_COMPONENT_INSTANCE";
20    pub const CONNECT: &str = "PHOXAL_CONNECT";
21    pub const CONFIG: &str = "PHOXAL_CONFIG";
22    pub const CLOCK: &str = "PHOXAL_CLOCK";
23
24    pub const ALL: &[&str] = &[
25        PARTICIPANT_ID,
26        EXECUTION_ID,
27        PRODUCER_ID,
28        EXECUTION_ORIGIN,
29        ROBOT_ID,
30        NAMESPACE,
31        BUNDLE_ROOT,
32        COMPONENT_INSTANCE,
33        CONNECT,
34        CONFIG,
35        CLOCK,
36    ];
37}
38
39/// One participant's process launch record.
40#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
41#[serde(deny_unknown_fields)]
42pub struct ParticipantLaunch {
43    pub participant_id: String,
44    pub execution: ExecutionId,
45    pub producer: ProducerId,
46    #[serde(default, with = "origin_serde")]
47    pub execution_origin: Option<ExecutionOrigin>,
48    pub namespace: String,
49    pub robot_id: String,
50    #[serde(default)]
51    pub bus: BusProfile,
52    #[serde(default)]
53    pub clock: ClockMode,
54    #[serde(default)]
55    pub config: Option<serde_json::Value>,
56    #[serde(default)]
57    pub bundle_root: Option<PathBuf>,
58    #[serde(default)]
59    pub component_instance: Option<String>,
60    #[serde(default = "default_grace")]
61    pub shutdown_grace_ms: u64,
62}
63
64const fn default_grace() -> u64 {
65    DEFAULT_SHUTDOWN_GRACE_MS
66}
67
68impl ParticipantLaunch {
69    pub fn local(participant_id: impl Into<String>, robot_id: impl Into<String>) -> Self {
70        Self {
71            participant_id: participant_id.into(),
72            execution: ExecutionId::mint(),
73            producer: ProducerId::mint(),
74            execution_origin: None,
75            namespace: "dev".to_string(),
76            robot_id: robot_id.into(),
77            bus: BusProfile::default(),
78            clock: ClockMode::Real,
79            config: None,
80            bundle_root: None,
81            component_instance: None,
82            shutdown_grace_ms: DEFAULT_SHUTDOWN_GRACE_MS,
83        }
84    }
85
86    pub fn in_execution(mut self, execution: ExecutionId) -> Self {
87        self.execution = execution;
88        self
89    }
90
91    pub fn with_execution_origin(mut self, origin: ExecutionOrigin) -> Self {
92        self.execution_origin = Some(origin);
93        self
94    }
95
96    /// Encode the complete shared environment ABI.
97    pub fn encode_env(&self) -> Result<Vec<(&'static str, String)>, LaunchError> {
98        let mut values = vec![
99            (env::PARTICIPANT_ID, self.participant_id.clone()),
100            (env::EXECUTION_ID, self.execution.to_string()),
101            (env::PRODUCER_ID, self.producer.to_string()),
102            (env::ROBOT_ID, self.robot_id.clone()),
103            (env::NAMESPACE, self.namespace.clone()),
104            (env::CONNECT, self.bus.connect_endpoints.join(",")),
105            (env::CLOCK, self.clock.to_string()),
106        ];
107        if let Some(origin) = self.execution_origin {
108            values.push((env::EXECUTION_ORIGIN, origin.encode()));
109        }
110        if let Some(root) = &self.bundle_root {
111            values.push((env::BUNDLE_ROOT, root.to_string_lossy().into_owned()));
112        }
113        if let Some(instance) = &self.component_instance {
114            values.push((env::COMPONENT_INSTANCE, instance.clone()));
115        }
116        if let Some(config) = &self.config {
117            values.push((env::CONFIG, serde_json::to_string(config)?));
118        }
119        Ok(values)
120    }
121
122    /// Decode values obtained from CLI flags or environment variables.
123    pub fn decode(values: LaunchEnv) -> Result<Self, LaunchError> {
124        let mut launch = Self::local(values.participant_id, values.robot_id);
125        if let Some(value) = nonempty(values.execution_id) {
126            launch.execution = ExecutionId::parse(&value)
127                .map_err(|source| LaunchError::ExecutionId { value, source })?;
128        }
129        if let Some(value) = nonempty(values.producer_id) {
130            launch.producer = ProducerId::parse(&value)
131                .map_err(|source| LaunchError::ProducerId { value, source })?;
132        }
133        if let Some(value) = nonempty(values.execution_origin) {
134            launch.execution_origin = Some(
135                ExecutionOrigin::decode(&value)
136                    .ok_or_else(|| LaunchError::ExecutionOrigin(value.clone()))?,
137            );
138        }
139        launch.namespace = nonempty(values.namespace).unwrap_or_else(|| "dev".to_string());
140        launch.bus.connect_endpoints = nonempty(values.connect)
141            .map(|endpoints| {
142                endpoints
143                    .split(',')
144                    .map(str::trim)
145                    .filter(|endpoint| !endpoint.is_empty())
146                    .map(str::to_string)
147                    .collect()
148            })
149            .unwrap_or_default();
150        launch.config = match nonempty(values.config) {
151            Some(value) => Some(
152                serde_json::from_str(&value)
153                    .map_err(|source| LaunchError::Config { value, source })?,
154            ),
155            None => None,
156        };
157        launch.bundle_root = values
158            .bundle_root
159            .filter(|path| !path.as_os_str().is_empty());
160        launch.component_instance = nonempty(values.component_instance);
161        launch.clock = values.clock;
162        Ok(launch)
163    }
164}
165
166fn nonempty(value: Option<String>) -> Option<String> {
167    value.filter(|value| !value.is_empty())
168}
169
170/// Raw values supplied by a runtime-specific CLI parser.
171#[derive(Clone, Debug, PartialEq, Eq)]
172pub struct LaunchEnv {
173    pub participant_id: String,
174    pub execution_id: Option<String>,
175    pub producer_id: Option<String>,
176    pub execution_origin: Option<String>,
177    pub robot_id: String,
178    pub namespace: Option<String>,
179    pub bundle_root: Option<PathBuf>,
180    pub component_instance: Option<String>,
181    pub connect: Option<String>,
182    pub config: Option<String>,
183    pub clock: ClockMode,
184}
185
186/// A malformed process-boundary launch record.
187#[derive(Debug, thiserror::Error)]
188pub enum LaunchError {
189    #[error("PHOXAL_EXECUTION_ID is invalid ('{value}'): {source}")]
190    ExecutionId {
191        value: String,
192        source: crate::InvalidIdentity,
193    },
194    #[error("PHOXAL_PRODUCER_ID is invalid ('{value}'): {source}")]
195    ProducerId {
196        value: String,
197        source: crate::InvalidIdentity,
198    },
199    #[error("PHOXAL_EXECUTION_ORIGIN is malformed: '{0}'")]
200    ExecutionOrigin(String),
201    #[error("PHOXAL_CONFIG is not valid JSON: {source}")]
202    Config {
203        value: String,
204        source: serde_json::Error,
205    },
206    #[error("could not encode launch JSON: {0}")]
207    Json(#[from] serde_json::Error),
208}
209
210/// A resolved bus profile.
211#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
212#[serde(deny_unknown_fields)]
213pub struct BusProfile {
214    #[serde(default)]
215    pub connect_endpoints: Vec<String>,
216}
217
218/// The participant scheduler's clock mode.
219#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
220#[serde(rename_all = "snake_case")]
221pub enum ClockMode {
222    #[default]
223    Real,
224    Simulation,
225    Clockless,
226}
227
228impl std::fmt::Display for ClockMode {
229    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
230        formatter.write_str(match self {
231            Self::Real => "real",
232            Self::Simulation => "simulation",
233            Self::Clockless => "clockless",
234        })
235    }
236}
237
238mod origin_serde {
239    use super::ExecutionOrigin;
240    use serde::{Deserialize, Deserializer, Serialize, Serializer};
241
242    pub fn serialize<S: Serializer>(
243        value: &Option<ExecutionOrigin>,
244        serializer: S,
245    ) -> Result<S::Ok, S::Error> {
246        value.map(ExecutionOrigin::encode).serialize(serializer)
247    }
248
249    pub fn deserialize<'de, D: Deserializer<'de>>(
250        deserializer: D,
251    ) -> Result<Option<ExecutionOrigin>, D::Error> {
252        let Some(rendered) = Option::<String>::deserialize(deserializer)? else {
253            return Ok(None);
254        };
255        ExecutionOrigin::decode(&rendered)
256            .map(Some)
257            .ok_or_else(|| serde::de::Error::custom("malformed execution origin"))
258    }
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264
265    #[test]
266    fn launch_env_round_trip_uses_bundle_root_only() {
267        let launch = ParticipantLaunch {
268            bundle_root: Some(PathBuf::from("/bundle")),
269            execution_origin: Some(ExecutionOrigin::mint()),
270            ..ParticipantLaunch::local("drive", "robot")
271        };
272        let encoded = launch.encode_env().unwrap();
273        assert!(
274            encoded
275                .iter()
276                .any(|(key, value)| { *key == env::BUNDLE_ROOT && value == "/bundle" })
277        );
278        assert!(!encoded.iter().any(|(key, _)| *key == "PHOXAL_ROBOT_ROOT"));
279    }
280}