leash-harness 0.1.1

Composable local-LLM and robotics harness with MCP, CLI, and safe robot adapters
Documentation
use std::{collections::HashSet, net::SocketAddr, str::FromStr};

use anyhow::{bail, Result};
use serde::{Deserialize, Serialize};

use crate::{
    capability::default_capability_descriptors,
    config::{HarnessConfig, PartialHarnessConfig, Profile},
    module::default_module_graph,
};

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "mcp", derive(schemars::JsonSchema))]
#[serde(rename_all = "kebab-case")]
pub enum StackTransport {
    Http,
    Mcp,
}

impl StackTransport {
    pub fn as_str(self) -> &'static str {
        match self {
            Self::Http => "http",
            Self::Mcp => "mcp",
        }
    }
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "mcp", derive(schemars::JsonSchema))]
pub struct TransportBinding {
    pub kind: StackTransport,
    pub listen: Option<SocketAddr>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "mcp", derive(schemars::JsonSchema))]
pub struct StackModule {
    pub name: String,
    pub module_type: String,
    pub required: bool,
    pub physical: bool,
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "mcp", derive(schemars::JsonSchema))]
pub struct Stack {
    pub name: String,
    pub description: String,
    pub profile: Profile,
    pub transport: TransportBinding,
    pub required_features: Vec<String>,
    pub hardware_required: bool,
    pub config_overrides: PartialHarnessConfig,
    pub modules: Vec<StackModule>,
    pub command: String,
}

impl Stack {
    pub fn validate(&self) -> Result<()> {
        if self.hardware_required != self.profile.is_physical() {
            bail!(
                "stack '{}' hardware metadata does not match profile '{}'",
                self.name,
                self.profile.as_str()
            );
        }

        let capabilities = default_capability_descriptors()
            .into_iter()
            .map(|descriptor| descriptor.name)
            .collect();
        let graph = default_module_graph(
            &HarnessConfig {
                profile: self.profile,
                ..HarnessConfig::default()
            },
            capabilities,
        );
        let graph_modules = graph
            .modules()
            .iter()
            .map(|module| module.name.as_str())
            .collect::<HashSet<_>>();

        for module in &self.modules {
            if !graph_modules.contains(module.name.as_str()) {
                bail!(
                    "stack '{}' references unknown module '{}'",
                    self.name,
                    module.name
                );
            }
        }
        Ok(())
    }
}

pub fn built_in_stacks() -> Vec<Stack> {
    vec![
        sim_http_stack(),
        sim_mcp_stack(),
        bridge_compat_http_stack(),
        waveshare_ugv_http_stack(),
    ]
}

pub fn find_stack(name: &str) -> Option<Stack> {
    built_in_stacks()
        .into_iter()
        .find(|stack| stack.name == name)
}

pub fn resolve_stack(name: &str) -> Result<Stack> {
    let Some(stack) = find_stack(name) else {
        let names = built_in_stacks()
            .into_iter()
            .map(|stack| stack.name)
            .collect::<Vec<_>>()
            .join(", ");
        bail!("unknown stack '{name}'; expected one of: {names}");
    };
    stack.validate()?;
    Ok(stack)
}

fn sim_http_stack() -> Stack {
    Stack {
        name: "sim-http".to_string(),
        description: "Simulation HTTP runtime with WebSocket telemetry".to_string(),
        profile: Profile::Sim,
        transport: TransportBinding {
            kind: StackTransport::Http,
            listen: Some(socket("127.0.0.1:8000")),
        },
        required_features: strings(&["sim", "http"]),
        hardware_required: false,
        config_overrides: PartialHarnessConfig {
            listen: Some(socket("127.0.0.1:8000")),
            ..PartialHarnessConfig::default()
        },
        modules: module_refs(Profile::Sim),
        command: "leash run sim-http".to_string(),
    }
}

fn sim_mcp_stack() -> Stack {
    Stack {
        name: "sim-mcp".to_string(),
        description: "Simulation stdio MCP server for local LLM clients".to_string(),
        profile: Profile::Sim,
        transport: TransportBinding {
            kind: StackTransport::Mcp,
            listen: None,
        },
        required_features: strings(&["sim", "mcp"]),
        hardware_required: false,
        config_overrides: PartialHarnessConfig::default(),
        modules: module_refs(Profile::Sim),
        command: "leash run sim-mcp".to_string(),
    }
}

fn bridge_compat_http_stack() -> Stack {
    Stack {
        name: "bridge-compat-http".to_string(),
        description: "Simulation HTTP runtime with bridge compatibility endpoints".to_string(),
        profile: Profile::Sim,
        transport: TransportBinding {
            kind: StackTransport::Http,
            listen: Some(socket("127.0.0.1:8001")),
        },
        required_features: strings(&["sim", "http", "bridge-compat"]),
        hardware_required: false,
        config_overrides: PartialHarnessConfig {
            listen: Some(socket("127.0.0.1:8001")),
            ..PartialHarnessConfig::default()
        },
        modules: module_refs(Profile::Sim),
        command: "leash run bridge-compat-http".to_string(),
    }
}

fn waveshare_ugv_http_stack() -> Stack {
    Stack {
        name: "waveshare-ugv-http".to_string(),
        description: "Gated Waveshare UGV HTTP runtime for bot installs".to_string(),
        profile: Profile::WaveshareUgv,
        transport: TransportBinding {
            kind: StackTransport::Http,
            listen: Some(socket("0.0.0.0:8000")),
        },
        required_features: strings(&["http", "waveshare-ugv"]),
        hardware_required: true,
        config_overrides: PartialHarnessConfig {
            listen: Some(socket("0.0.0.0:8000")),
            ..PartialHarnessConfig::default()
        },
        modules: module_refs(Profile::WaveshareUgv),
        command:
            "LEASH_ALLOW_PHYSICAL_ACTUATION=1 leash run waveshare-ugv-http --allow-physical-actuation"
                .to_string(),
    }
}

fn module_refs(profile: Profile) -> Vec<StackModule> {
    let driver = match profile {
        Profile::Sim => ("sim-driver", false),
        Profile::Replay => ("replay-driver", false),
        Profile::WaveshareUgv => ("waveshare-ugv-driver", true),
    };
    vec![
        stack_module("harness-runtime", "runtime", true, false),
        stack_module(driver.0, "driver", true, driver.1),
        stack_module("telemetry", "telemetry", true, false),
    ]
}

fn stack_module(name: &str, module_type: &str, required: bool, physical: bool) -> StackModule {
    StackModule {
        name: name.to_string(),
        module_type: module_type.to_string(),
        required,
        physical,
    }
}

fn strings(values: &[&str]) -> Vec<String> {
    values.iter().map(|value| (*value).to_string()).collect()
}

fn socket(value: &str) -> SocketAddr {
    SocketAddr::from_str(value).expect("built-in stack socket is valid")
}

#[cfg(test)]
mod tests {
    use std::collections::BTreeMap;

    use super::*;
    use crate::config::{resolve_config, ConfigRequest};

    #[test]
    fn built_in_stacks_validate() {
        let stacks = built_in_stacks();
        assert!(stacks.iter().any(|stack| stack.name == "sim-http"));
        assert!(stacks.iter().any(|stack| stack.name == "sim-mcp"));
        assert!(stacks
            .iter()
            .any(|stack| stack.name == "bridge-compat-http"));
        assert!(stacks
            .iter()
            .any(|stack| stack.name == "waveshare-ugv-http"));

        for stack in stacks {
            stack.validate().unwrap();
        }
    }

    #[test]
    fn unknown_stack_reports_known_names() {
        let err = resolve_stack("missing").unwrap_err().to_string();
        assert!(err.contains("unknown stack 'missing'"));
        assert!(err.contains("sim-http"));
    }

    #[test]
    fn physical_stack_resolves_before_refusing_without_gate() {
        let stack = resolve_stack("waveshare-ugv-http").unwrap();
        let config = resolve_config(ConfigRequest {
            config_path: None,
            stack: Some(stack.profile),
            stack_defaults: stack.config_overrides,
            env: BTreeMap::new(),
            cli: PartialHarnessConfig::default(),
        })
        .unwrap()
        .config;

        let err = config.validate().unwrap_err().to_string();
        assert!(err.contains("physical profile 'waveshare-ugv' refuses to start"));
    }
}