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}