mecha10_node_resolver/
lib.rs

1//! Mecha10 Node Resolver
2//!
3//! Lightweight runtime resolver for downloading and spawning mecha10 framework nodes.
4//! This crate is designed to be embedded in robot binaries to enable runtime download
5//! of pre-compiled framework nodes, avoiding slow cross-compilation during build.
6//!
7//! # Architecture
8//!
9//! ```text
10//! Robot Binary (thin)
11//!   │
12//!   ├── Custom nodes (compiled in)
13//!   │
14//!   └── NodeResolver
15//!         │
16//!         ├── resolve("speaker") → downloads if needed
17//!         │
18//!         └── spawn("speaker", env) → runs as subprocess
19//!                 │
20//!                 └── ~/.mecha10/bin/speaker-0.1.44-aarch64-linux-musl
21//! ```
22//!
23//! # Usage
24//!
25//! ```no_run
26//! use mecha10_node_resolver::NodeResolver;
27//! use std::collections::HashMap;
28//!
29//! # async fn example() -> anyhow::Result<()> {
30//! let resolver = NodeResolver::new()?;
31//!
32//! // Check if it's a framework node
33//! if NodeResolver::is_framework_node("@mecha10/speaker") {
34//!     // Resolve (downloads if needed)
35//!     let binary_path = resolver.resolve("speaker").await?;
36//!
37//!     // Spawn as subprocess
38//!     let mut env = HashMap::new();
39//!     env.insert("REDIS_URL".to_string(), "redis://localhost:6379".to_string());
40//!     let child = resolver.spawn("speaker", env).await?;
41//! }
42//! # Ok(())
43//! # }
44//! ```
45
46use anyhow::{Context, Result};
47use std::collections::HashMap;
48use std::path::PathBuf;
49use std::process::Stdio;
50use tokio::process::{Child, Command};
51
52/// GitHub repository for node binaries
53const GITHUB_REPO: &str = "mecha-industries/user-tools";
54
55/// Framework version (embedded at compile time)
56const FRAMEWORK_VERSION: &str = env!("CARGO_PKG_VERSION");
57
58/// Supported pre-built targets
59/// Note: Linux uses gnu targets for better compatibility with system libraries
60/// (audio, USB, etc.) since robots typically run Raspberry Pi OS / Ubuntu (glibc)
61const SUPPORTED_TARGETS: &[&str] = &[
62    "aarch64-apple-darwin",
63    "x86_64-apple-darwin",
64    "x86_64-unknown-linux-gnu",
65    "aarch64-unknown-linux-gnu",
66];
67
68/// Node resolver for downloading and spawning framework nodes
69#[derive(Clone)]
70pub struct NodeResolver {
71    /// Binary cache directory (~/.mecha10/bin)
72    cache_dir: PathBuf,
73    /// Framework version
74    version: String,
75    /// Target triple for this platform
76    target: String,
77}
78
79impl NodeResolver {
80    /// Create a new node resolver
81    pub fn new() -> Result<Self> {
82        let cache_dir = Self::default_cache_dir()?;
83        let version = FRAMEWORK_VERSION.to_string();
84        let target = Self::detect_target()?;
85
86        Ok(Self {
87            cache_dir,
88            version,
89            target,
90        })
91    }
92
93    /// Create a resolver with a specific version
94    pub fn with_version(version: String) -> Result<Self> {
95        let cache_dir = Self::default_cache_dir()?;
96        let target = Self::detect_target()?;
97
98        Ok(Self {
99            cache_dir,
100            version,
101            target,
102        })
103    }
104
105    /// Get the default cache directory
106    fn default_cache_dir() -> Result<PathBuf> {
107        let home = std::env::var("HOME").context("HOME environment variable not set")?;
108        Ok(PathBuf::from(home).join(".mecha10").join("bin"))
109    }
110
111    /// Detect the current platform's target triple
112    fn detect_target() -> Result<String> {
113        let target = match (std::env::consts::OS, std::env::consts::ARCH) {
114            ("macos", "aarch64") => "aarch64-apple-darwin",
115            ("macos", "x86_64") => "x86_64-apple-darwin",
116            // Linux uses gnu for compatibility with system libraries (audio, USB, etc.)
117            ("linux", "x86_64") => "x86_64-unknown-linux-gnu",
118            ("linux", "aarch64") => "aarch64-unknown-linux-gnu",
119            (os, arch) => {
120                return Err(anyhow::anyhow!(
121                    "Unsupported platform: {}-{}. Pre-built binaries not available.",
122                    os,
123                    arch
124                ))
125            }
126        };
127        Ok(target.to_string())
128    }
129
130    /// Check if a node name is a framework node (@mecha10/*)
131    pub fn is_framework_node(name: &str) -> bool {
132        name.starts_with("@mecha10/")
133    }
134
135    /// Extract the short name from a framework node identifier
136    ///
137    /// "@mecha10/speaker" -> "speaker"
138    pub fn short_name(name: &str) -> Option<&str> {
139        name.strip_prefix("@mecha10/")
140    }
141
142    /// Check if pre-built binaries are available for this platform
143    pub fn is_prebuilt_available(&self) -> bool {
144        SUPPORTED_TARGETS.contains(&self.target.as_str())
145    }
146
147    /// Get the version
148    pub fn version(&self) -> &str {
149        &self.version
150    }
151
152    /// Get the target
153    pub fn target(&self) -> &str {
154        &self.target
155    }
156
157    /// Resolve a framework node binary, downloading if needed
158    ///
159    /// # Arguments
160    ///
161    /// * `node_name` - Short name of the node (e.g., "speaker", not "@mecha10/speaker")
162    ///
163    /// # Returns
164    ///
165    /// Path to the executable binary
166    pub async fn resolve(&self, node_name: &str) -> Result<PathBuf> {
167        // Check cache first
168        if let Some(cached) = self.find_cached(node_name) {
169            tracing::debug!("Using cached binary: {}", cached.display());
170            return Ok(cached);
171        }
172
173        // Try to download
174        if self.is_prebuilt_available() {
175            match self.download(node_name).await {
176                Ok(path) => {
177                    tracing::info!("Downloaded binary for {}: {}", node_name, path.display());
178                    return Ok(path);
179                }
180                Err(e) => {
181                    tracing::warn!("Failed to download binary for {}: {}", node_name, e);
182                }
183            }
184        } else {
185            tracing::warn!("Pre-built binary not available for {} on {}", node_name, self.target);
186        }
187
188        Err(anyhow::anyhow!(
189            "Could not resolve binary for node '{}'. \
190             Pre-built binary not available for target '{}'.",
191            node_name,
192            self.target
193        ))
194    }
195
196    /// Spawn a framework node as a subprocess
197    ///
198    /// # Arguments
199    ///
200    /// * `node_name` - Short name of the node (e.g., "speaker")
201    /// * `env` - Environment variables to pass to the node
202    ///
203    /// # Returns
204    ///
205    /// The spawned child process
206    pub async fn spawn(&self, node_name: &str, env: HashMap<String, String>) -> Result<Child> {
207        let binary_path = self.resolve(node_name).await?;
208
209        tracing::info!("Spawning node {} from {}", node_name, binary_path.display());
210
211        let child = Command::new(&binary_path)
212            .envs(env)
213            .stdout(Stdio::piped())
214            .stderr(Stdio::piped())
215            .spawn()
216            .with_context(|| format!("Failed to spawn node {}", node_name))?;
217
218        Ok(child)
219    }
220
221    /// Spawn a framework node and wait for it to complete
222    pub async fn spawn_and_wait(&self, node_name: &str, env: HashMap<String, String>) -> Result<()> {
223        let binary_path = self.resolve(node_name).await?;
224
225        tracing::info!("Running node {} from {}", node_name, binary_path.display());
226
227        let status = Command::new(&binary_path)
228            .envs(env)
229            .status()
230            .await
231            .with_context(|| format!("Failed to run node {}", node_name))?;
232
233        if !status.success() {
234            return Err(anyhow::anyhow!("Node {} exited with status: {}", node_name, status));
235        }
236
237        Ok(())
238    }
239
240    /// Check for cached binary
241    fn find_cached(&self, node_name: &str) -> Option<PathBuf> {
242        let binary_name = self.binary_name(node_name);
243        let path = self.cache_dir.join(&binary_name);
244
245        if path.exists() && path.is_file() {
246            #[cfg(unix)]
247            {
248                use std::os::unix::fs::PermissionsExt;
249                if let Ok(metadata) = path.metadata() {
250                    if metadata.permissions().mode() & 0o111 != 0 {
251                        return Some(path);
252                    }
253                }
254            }
255
256            #[cfg(not(unix))]
257            {
258                return Some(path);
259            }
260        }
261
262        // Also check symlink without version
263        let symlink_path = self.cache_dir.join(node_name);
264        if symlink_path.exists() {
265            if let Ok(resolved) = std::fs::canonicalize(&symlink_path) {
266                if resolved.exists() {
267                    return Some(resolved);
268                }
269            }
270        }
271
272        None
273    }
274
275    /// Build the binary name for a node
276    fn binary_name(&self, node_name: &str) -> String {
277        format!("{}-{}-{}", node_name, self.version, self.target)
278    }
279
280    /// Build the tarball name for a node
281    fn tarball_name(&self, node_name: &str) -> String {
282        format!("{}-{}-{}.tar.gz", node_name, self.version, self.target)
283    }
284
285    /// Download binary from GitHub releases
286    async fn download(&self, node_name: &str) -> Result<PathBuf> {
287        let client = reqwest::Client::builder()
288            .user_agent("mecha10-node-resolver")
289            .build()
290            .context("Failed to build HTTP client")?;
291
292        let tarball_name = self.tarball_name(node_name);
293        let release_tag = format!("v{}", self.version);
294
295        // Try to get release info
296        let release_url = format!(
297            "https://api.github.com/repos/{}/releases/tags/{}",
298            GITHUB_REPO, release_tag
299        );
300
301        tracing::info!("Downloading {} (v{})...", node_name, self.version);
302
303        let response = client
304            .get(&release_url)
305            .send()
306            .await
307            .context("Failed to fetch release info from GitHub")?;
308
309        if !response.status().is_success() {
310            let status = response.status();
311            if status.as_u16() == 404 {
312                return Err(anyhow::anyhow!(
313                    "Release v{} not found. Node binaries may not be published yet.",
314                    self.version
315                ));
316            }
317            return Err(anyhow::anyhow!("Failed to get release info: HTTP {}", status));
318        }
319
320        let release: serde_json::Value = response.json().await?;
321
322        // Find the asset
323        let assets = release["assets"]
324            .as_array()
325            .ok_or_else(|| anyhow::anyhow!("No assets in release"))?;
326
327        let asset = assets
328            .iter()
329            .find(|a| a["name"].as_str().map(|n| n == tarball_name).unwrap_or(false))
330            .ok_or_else(|| anyhow::anyhow!("Binary '{}' not found in release v{}.", tarball_name, self.version))?;
331
332        let download_url = asset["browser_download_url"]
333            .as_str()
334            .ok_or_else(|| anyhow::anyhow!("No download URL for asset"))?;
335
336        // Download
337        let response = client
338            .get(download_url)
339            .send()
340            .await
341            .context("Failed to download binary")?;
342
343        if !response.status().is_success() {
344            return Err(anyhow::anyhow!("Download failed: HTTP {}", response.status()));
345        }
346
347        // Download to temp file
348        let temp_dir = tempdir()?;
349        let temp_file = temp_dir.path().join("binary.tar.gz");
350
351        let bytes = response.bytes().await?;
352        tokio::fs::write(&temp_file, &bytes).await?;
353
354        // Create cache directory
355        tokio::fs::create_dir_all(&self.cache_dir).await?;
356
357        // Extract tarball
358        let tar_gz = std::fs::File::open(&temp_file)?;
359        let tar = flate2::read::GzDecoder::new(tar_gz);
360        let mut archive = tar::Archive::new(tar);
361
362        let extract_dir = temp_dir.path().join("extract");
363        std::fs::create_dir_all(&extract_dir)?;
364        archive.unpack(&extract_dir)?;
365
366        // Find the binary
367        let extracted_binary = extract_dir.join(node_name);
368        if !extracted_binary.exists() {
369            return Err(anyhow::anyhow!("Binary '{}' not found in tarball", node_name));
370        }
371
372        // Move to cache
373        let binary_name = self.binary_name(node_name);
374        let dest_path = self.cache_dir.join(&binary_name);
375
376        tokio::fs::copy(&extracted_binary, &dest_path).await?;
377
378        // Make executable
379        #[cfg(unix)]
380        {
381            use std::os::unix::fs::PermissionsExt;
382            let mut perms = tokio::fs::metadata(&dest_path).await?.permissions();
383            perms.set_mode(0o755);
384            tokio::fs::set_permissions(&dest_path, perms).await?;
385        }
386
387        // Create symlink
388        let symlink_path = self.cache_dir.join(node_name);
389        if symlink_path.exists() || symlink_path.is_symlink() {
390            tokio::fs::remove_file(&symlink_path).await.ok();
391        }
392
393        #[cfg(unix)]
394        {
395            std::os::unix::fs::symlink(&binary_name, &symlink_path)?;
396        }
397
398        tracing::info!("Installed {} to {}", node_name, dest_path.display());
399
400        Ok(dest_path)
401    }
402
403    /// Resolve all framework nodes from a list, downloading in parallel
404    pub async fn resolve_all(&self, nodes: &[String]) -> Result<HashMap<String, PathBuf>> {
405        use futures_util::future::join_all;
406
407        let framework_nodes: Vec<_> = nodes
408            .iter()
409            .filter(|n| Self::is_framework_node(n))
410            .filter_map(|n| Self::short_name(n).map(|s| s.to_string()))
411            .collect();
412
413        if framework_nodes.is_empty() {
414            return Ok(HashMap::new());
415        }
416
417        tracing::info!("Resolving {} framework nodes...", framework_nodes.len());
418
419        let futures = framework_nodes.iter().map(|name| {
420            let resolver = self.clone();
421            let name = name.clone();
422            async move {
423                let result = resolver.resolve(&name).await;
424                (name, result)
425            }
426        });
427
428        let results = join_all(futures).await;
429
430        let mut resolved = HashMap::new();
431        for (name, result) in results {
432            match result {
433                Ok(path) => {
434                    resolved.insert(name, path);
435                }
436                Err(e) => {
437                    return Err(anyhow::anyhow!("Failed to resolve node '{}': {}", name, e));
438                }
439            }
440        }
441
442        tracing::info!("All {} framework nodes ready", resolved.len());
443        Ok(resolved)
444    }
445}
446
447impl Default for NodeResolver {
448    fn default() -> Self {
449        Self::new().expect("Failed to create NodeResolver")
450    }
451}
452
453// Inline tempdir implementation to avoid external dependency
454struct TempDir {
455    path: PathBuf,
456}
457
458impl TempDir {
459    fn path(&self) -> &std::path::Path {
460        &self.path
461    }
462}
463
464impl Drop for TempDir {
465    fn drop(&mut self) {
466        let _ = std::fs::remove_dir_all(&self.path);
467    }
468}
469
470fn tempdir() -> std::io::Result<TempDir> {
471    let mut path = std::env::temp_dir();
472    path.push(format!("mecha10-node-resolver-{}-{}", std::process::id(), rand_u64()));
473    std::fs::create_dir_all(&path)?;
474    Ok(TempDir { path })
475}
476
477fn rand_u64() -> u64 {
478    use std::time::{SystemTime, UNIX_EPOCH};
479    SystemTime::now()
480        .duration_since(UNIX_EPOCH)
481        .map(|d| d.as_nanos() as u64)
482        .unwrap_or(0)
483}
484
485#[cfg(test)]
486mod tests {
487    use super::*;
488
489    #[test]
490    fn test_is_framework_node() {
491        assert!(NodeResolver::is_framework_node("@mecha10/speaker"));
492        assert!(NodeResolver::is_framework_node("@mecha10/motor"));
493        assert!(!NodeResolver::is_framework_node("my-custom-node"));
494        assert!(!NodeResolver::is_framework_node("speaker"));
495    }
496
497    #[test]
498    fn test_short_name() {
499        assert_eq!(NodeResolver::short_name("@mecha10/speaker"), Some("speaker"));
500        assert_eq!(NodeResolver::short_name("@mecha10/motor"), Some("motor"));
501        assert_eq!(NodeResolver::short_name("my-custom-node"), None);
502    }
503
504    #[test]
505    fn test_binary_name() {
506        let resolver = NodeResolver {
507            cache_dir: PathBuf::from("/tmp"),
508            version: "0.1.44".to_string(),
509            target: "aarch64-unknown-linux-gnu".to_string(),
510        };
511
512        assert_eq!(
513            resolver.binary_name("speaker"),
514            "speaker-0.1.44-aarch64-unknown-linux-gnu"
515        );
516    }
517}