Skip to main content

leash_harness/
stack.rs

1use std::{collections::HashSet, net::SocketAddr, str::FromStr};
2
3use anyhow::{bail, Result};
4use serde::{Deserialize, Serialize};
5
6use crate::{
7    capability::default_capability_descriptors,
8    config::{HarnessConfig, PartialHarnessConfig, Profile},
9    module::default_module_graph,
10};
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
13#[cfg_attr(feature = "mcp", derive(schemars::JsonSchema))]
14#[serde(rename_all = "kebab-case")]
15pub enum StackTransport {
16    Http,
17    Mcp,
18}
19
20impl StackTransport {
21    pub fn as_str(self) -> &'static str {
22        match self {
23            Self::Http => "http",
24            Self::Mcp => "mcp",
25        }
26    }
27}
28
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
30#[cfg_attr(feature = "mcp", derive(schemars::JsonSchema))]
31pub struct TransportBinding {
32    pub kind: StackTransport,
33    pub listen: Option<SocketAddr>,
34}
35
36#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
37#[cfg_attr(feature = "mcp", derive(schemars::JsonSchema))]
38pub struct StackModule {
39    pub name: String,
40    pub module_type: String,
41    pub required: bool,
42    pub physical: bool,
43}
44
45#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
46#[cfg_attr(feature = "mcp", derive(schemars::JsonSchema))]
47pub struct Stack {
48    pub name: String,
49    pub description: String,
50    pub profile: Profile,
51    pub transport: TransportBinding,
52    pub required_features: Vec<String>,
53    pub hardware_required: bool,
54    pub config_overrides: PartialHarnessConfig,
55    pub modules: Vec<StackModule>,
56    pub command: String,
57}
58
59impl Stack {
60    pub fn validate(&self) -> Result<()> {
61        if self.hardware_required != self.profile.is_physical() {
62            bail!(
63                "stack '{}' hardware metadata does not match profile '{}'",
64                self.name,
65                self.profile.as_str()
66            );
67        }
68
69        let capabilities = default_capability_descriptors()
70            .into_iter()
71            .map(|descriptor| descriptor.name)
72            .collect();
73        let graph = default_module_graph(
74            &HarnessConfig {
75                profile: self.profile,
76                ..HarnessConfig::default()
77            },
78            capabilities,
79        );
80        let graph_modules = graph
81            .modules()
82            .iter()
83            .map(|module| module.name.as_str())
84            .collect::<HashSet<_>>();
85
86        for module in &self.modules {
87            if !graph_modules.contains(module.name.as_str()) {
88                bail!(
89                    "stack '{}' references unknown module '{}'",
90                    self.name,
91                    module.name
92                );
93            }
94        }
95        Ok(())
96    }
97}
98
99pub fn built_in_stacks() -> Vec<Stack> {
100    vec![
101        sim_http_stack(),
102        sim_mcp_stack(),
103        bridge_compat_http_stack(),
104        waveshare_ugv_http_stack(),
105    ]
106}
107
108pub fn find_stack(name: &str) -> Option<Stack> {
109    built_in_stacks()
110        .into_iter()
111        .find(|stack| stack.name == name)
112}
113
114pub fn resolve_stack(name: &str) -> Result<Stack> {
115    let Some(stack) = find_stack(name) else {
116        let names = built_in_stacks()
117            .into_iter()
118            .map(|stack| stack.name)
119            .collect::<Vec<_>>()
120            .join(", ");
121        bail!("unknown stack '{name}'; expected one of: {names}");
122    };
123    stack.validate()?;
124    Ok(stack)
125}
126
127fn sim_http_stack() -> Stack {
128    Stack {
129        name: "sim-http".to_string(),
130        description: "Simulation HTTP runtime with WebSocket telemetry".to_string(),
131        profile: Profile::Sim,
132        transport: TransportBinding {
133            kind: StackTransport::Http,
134            listen: Some(socket("127.0.0.1:8000")),
135        },
136        required_features: strings(&["sim", "http"]),
137        hardware_required: false,
138        config_overrides: PartialHarnessConfig {
139            listen: Some(socket("127.0.0.1:8000")),
140            ..PartialHarnessConfig::default()
141        },
142        modules: module_refs(Profile::Sim),
143        command: "leash run sim-http".to_string(),
144    }
145}
146
147fn sim_mcp_stack() -> Stack {
148    Stack {
149        name: "sim-mcp".to_string(),
150        description: "Simulation stdio MCP server for local LLM clients".to_string(),
151        profile: Profile::Sim,
152        transport: TransportBinding {
153            kind: StackTransport::Mcp,
154            listen: None,
155        },
156        required_features: strings(&["sim", "mcp"]),
157        hardware_required: false,
158        config_overrides: PartialHarnessConfig::default(),
159        modules: module_refs(Profile::Sim),
160        command: "leash run sim-mcp".to_string(),
161    }
162}
163
164fn bridge_compat_http_stack() -> Stack {
165    Stack {
166        name: "bridge-compat-http".to_string(),
167        description: "Simulation HTTP runtime with bridge compatibility endpoints".to_string(),
168        profile: Profile::Sim,
169        transport: TransportBinding {
170            kind: StackTransport::Http,
171            listen: Some(socket("127.0.0.1:8001")),
172        },
173        required_features: strings(&["sim", "http", "bridge-compat"]),
174        hardware_required: false,
175        config_overrides: PartialHarnessConfig {
176            listen: Some(socket("127.0.0.1:8001")),
177            ..PartialHarnessConfig::default()
178        },
179        modules: module_refs(Profile::Sim),
180        command: "leash run bridge-compat-http".to_string(),
181    }
182}
183
184fn waveshare_ugv_http_stack() -> Stack {
185    Stack {
186        name: "waveshare-ugv-http".to_string(),
187        description: "Gated Waveshare UGV HTTP runtime for bot installs".to_string(),
188        profile: Profile::WaveshareUgv,
189        transport: TransportBinding {
190            kind: StackTransport::Http,
191            listen: Some(socket("0.0.0.0:8000")),
192        },
193        required_features: strings(&["http", "waveshare-ugv"]),
194        hardware_required: true,
195        config_overrides: PartialHarnessConfig {
196            listen: Some(socket("0.0.0.0:8000")),
197            ..PartialHarnessConfig::default()
198        },
199        modules: module_refs(Profile::WaveshareUgv),
200        command:
201            "LEASH_ALLOW_PHYSICAL_ACTUATION=1 leash run waveshare-ugv-http --allow-physical-actuation"
202                .to_string(),
203    }
204}
205
206fn module_refs(profile: Profile) -> Vec<StackModule> {
207    let driver = match profile {
208        Profile::Sim => ("sim-driver", false),
209        Profile::Replay => ("replay-driver", false),
210        Profile::WaveshareUgv => ("waveshare-ugv-driver", true),
211    };
212    vec![
213        stack_module("harness-runtime", "runtime", true, false),
214        stack_module(driver.0, "driver", true, driver.1),
215        stack_module("telemetry", "telemetry", true, false),
216    ]
217}
218
219fn stack_module(name: &str, module_type: &str, required: bool, physical: bool) -> StackModule {
220    StackModule {
221        name: name.to_string(),
222        module_type: module_type.to_string(),
223        required,
224        physical,
225    }
226}
227
228fn strings(values: &[&str]) -> Vec<String> {
229    values.iter().map(|value| (*value).to_string()).collect()
230}
231
232fn socket(value: &str) -> SocketAddr {
233    SocketAddr::from_str(value).expect("built-in stack socket is valid")
234}
235
236#[cfg(test)]
237mod tests {
238    use std::collections::BTreeMap;
239
240    use super::*;
241    use crate::config::{resolve_config, ConfigRequest};
242
243    #[test]
244    fn built_in_stacks_validate() {
245        let stacks = built_in_stacks();
246        assert!(stacks.iter().any(|stack| stack.name == "sim-http"));
247        assert!(stacks.iter().any(|stack| stack.name == "sim-mcp"));
248        assert!(stacks
249            .iter()
250            .any(|stack| stack.name == "bridge-compat-http"));
251        assert!(stacks
252            .iter()
253            .any(|stack| stack.name == "waveshare-ugv-http"));
254
255        for stack in stacks {
256            stack.validate().unwrap();
257        }
258    }
259
260    #[test]
261    fn unknown_stack_reports_known_names() {
262        let err = resolve_stack("missing").unwrap_err().to_string();
263        assert!(err.contains("unknown stack 'missing'"));
264        assert!(err.contains("sim-http"));
265    }
266
267    #[test]
268    fn physical_stack_resolves_before_refusing_without_gate() {
269        let stack = resolve_stack("waveshare-ugv-http").unwrap();
270        let config = resolve_config(ConfigRequest {
271            config_path: None,
272            stack: Some(stack.profile),
273            stack_defaults: stack.config_overrides,
274            env: BTreeMap::new(),
275            cli: PartialHarnessConfig::default(),
276        })
277        .unwrap()
278        .config;
279
280        let err = config.validate().unwrap_err().to_string();
281        assert!(err.contains("physical profile 'waveshare-ugv' refuses to start"));
282    }
283}