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!(
186                "Pre-built binary not available for {} on {}",
187                node_name,
188                self.target
189            );
190        }
191
192        Err(anyhow::anyhow!(
193            "Could not resolve binary for node '{}'. \
194             Pre-built binary not available for target '{}'.",
195            node_name,
196            self.target
197        ))
198    }
199
200    /// Spawn a framework node as a subprocess
201    ///
202    /// # Arguments
203    ///
204    /// * `node_name` - Short name of the node (e.g., "speaker")
205    /// * `env` - Environment variables to pass to the node
206    ///
207    /// # Returns
208    ///
209    /// The spawned child process
210    pub async fn spawn(&self, node_name: &str, env: HashMap<String, String>) -> Result<Child> {
211        let binary_path = self.resolve(node_name).await?;
212
213        tracing::info!("Spawning node {} from {}", node_name, binary_path.display());
214
215        let child = Command::new(&binary_path)
216            .envs(env)
217            .stdout(Stdio::piped())
218            .stderr(Stdio::piped())
219            .spawn()
220            .with_context(|| format!("Failed to spawn node {}", node_name))?;
221
222        Ok(child)
223    }
224
225    /// Spawn a framework node and wait for it to complete
226    pub async fn spawn_and_wait(&self, node_name: &str, env: HashMap<String, String>) -> Result<()> {
227        let binary_path = self.resolve(node_name).await?;
228
229        tracing::info!("Running node {} from {}", node_name, binary_path.display());
230
231        let status = Command::new(&binary_path)
232            .envs(env)
233            .status()
234            .await
235            .with_context(|| format!("Failed to run node {}", node_name))?;
236
237        if !status.success() {
238            return Err(anyhow::anyhow!(
239                "Node {} exited with status: {}",
240                node_name,
241                status
242            ));
243        }
244
245        Ok(())
246    }
247
248    /// Check for cached binary
249    fn find_cached(&self, node_name: &str) -> Option<PathBuf> {
250        let binary_name = self.binary_name(node_name);
251        let path = self.cache_dir.join(&binary_name);
252
253        if path.exists() && path.is_file() {
254            #[cfg(unix)]
255            {
256                use std::os::unix::fs::PermissionsExt;
257                if let Ok(metadata) = path.metadata() {
258                    if metadata.permissions().mode() & 0o111 != 0 {
259                        return Some(path);
260                    }
261                }
262            }
263
264            #[cfg(not(unix))]
265            {
266                return Some(path);
267            }
268        }
269
270        // Also check symlink without version
271        let symlink_path = self.cache_dir.join(node_name);
272        if symlink_path.exists() {
273            if let Ok(resolved) = std::fs::canonicalize(&symlink_path) {
274                if resolved.exists() {
275                    return Some(resolved);
276                }
277            }
278        }
279
280        None
281    }
282
283    /// Build the binary name for a node
284    fn binary_name(&self, node_name: &str) -> String {
285        format!("{}-{}-{}", node_name, self.version, self.target)
286    }
287
288    /// Build the tarball name for a node
289    fn tarball_name(&self, node_name: &str) -> String {
290        format!("{}-{}-{}.tar.gz", node_name, self.version, self.target)
291    }
292
293    /// Download binary from GitHub releases
294    async fn download(&self, node_name: &str) -> Result<PathBuf> {
295        let client = reqwest::Client::builder()
296            .user_agent("mecha10-node-resolver")
297            .build()
298            .context("Failed to build HTTP client")?;
299
300        let tarball_name = self.tarball_name(node_name);
301        let release_tag = format!("v{}", self.version);
302
303        // Try to get release info
304        let release_url = format!(
305            "https://api.github.com/repos/{}/releases/tags/{}",
306            GITHUB_REPO, release_tag
307        );
308
309        tracing::info!("Downloading {} (v{})...", node_name, self.version);
310
311        let response = client
312            .get(&release_url)
313            .send()
314            .await
315            .context("Failed to fetch release info from GitHub")?;
316
317        if !response.status().is_success() {
318            let status = response.status();
319            if status.as_u16() == 404 {
320                return Err(anyhow::anyhow!(
321                    "Release v{} not found. Node binaries may not be published yet.",
322                    self.version
323                ));
324            }
325            return Err(anyhow::anyhow!("Failed to get release info: HTTP {}", status));
326        }
327
328        let release: serde_json::Value = response.json().await?;
329
330        // Find the asset
331        let assets = release["assets"]
332            .as_array()
333            .ok_or_else(|| anyhow::anyhow!("No assets in release"))?;
334
335        let asset = assets
336            .iter()
337            .find(|a| a["name"].as_str().map(|n| n == tarball_name).unwrap_or(false))
338            .ok_or_else(|| {
339                anyhow::anyhow!(
340                    "Binary '{}' not found in release v{}.",
341                    tarball_name,
342                    self.version
343                )
344            })?;
345
346        let download_url = asset["browser_download_url"]
347            .as_str()
348            .ok_or_else(|| anyhow::anyhow!("No download URL for asset"))?;
349
350        // Download
351        let response = client
352            .get(download_url)
353            .send()
354            .await
355            .context("Failed to download binary")?;
356
357        if !response.status().is_success() {
358            return Err(anyhow::anyhow!("Download failed: HTTP {}", response.status()));
359        }
360
361        // Download to temp file
362        let temp_dir = tempdir()?;
363        let temp_file = temp_dir.path().join("binary.tar.gz");
364
365        let bytes = response.bytes().await?;
366        tokio::fs::write(&temp_file, &bytes).await?;
367
368        // Create cache directory
369        tokio::fs::create_dir_all(&self.cache_dir).await?;
370
371        // Extract tarball
372        let tar_gz = std::fs::File::open(&temp_file)?;
373        let tar = flate2::read::GzDecoder::new(tar_gz);
374        let mut archive = tar::Archive::new(tar);
375
376        let extract_dir = temp_dir.path().join("extract");
377        std::fs::create_dir_all(&extract_dir)?;
378        archive.unpack(&extract_dir)?;
379
380        // Find the binary
381        let extracted_binary = extract_dir.join(node_name);
382        if !extracted_binary.exists() {
383            return Err(anyhow::anyhow!("Binary '{}' not found in tarball", node_name));
384        }
385
386        // Move to cache
387        let binary_name = self.binary_name(node_name);
388        let dest_path = self.cache_dir.join(&binary_name);
389
390        tokio::fs::copy(&extracted_binary, &dest_path).await?;
391
392        // Make executable
393        #[cfg(unix)]
394        {
395            use std::os::unix::fs::PermissionsExt;
396            let mut perms = tokio::fs::metadata(&dest_path).await?.permissions();
397            perms.set_mode(0o755);
398            tokio::fs::set_permissions(&dest_path, perms).await?;
399        }
400
401        // Create symlink
402        let symlink_path = self.cache_dir.join(node_name);
403        if symlink_path.exists() || symlink_path.is_symlink() {
404            tokio::fs::remove_file(&symlink_path).await.ok();
405        }
406
407        #[cfg(unix)]
408        {
409            std::os::unix::fs::symlink(&binary_name, &symlink_path)?;
410        }
411
412        tracing::info!("Installed {} to {}", node_name, dest_path.display());
413
414        Ok(dest_path)
415    }
416
417    /// Resolve all framework nodes from a list, downloading in parallel
418    pub async fn resolve_all(&self, nodes: &[String]) -> Result<HashMap<String, PathBuf>> {
419        use futures_util::future::join_all;
420
421        let framework_nodes: Vec<_> = nodes
422            .iter()
423            .filter(|n| Self::is_framework_node(n))
424            .filter_map(|n| Self::short_name(n).map(|s| s.to_string()))
425            .collect();
426
427        if framework_nodes.is_empty() {
428            return Ok(HashMap::new());
429        }
430
431        tracing::info!("Resolving {} framework nodes...", framework_nodes.len());
432
433        let futures = framework_nodes.iter().map(|name| {
434            let resolver = self.clone();
435            let name = name.clone();
436            async move {
437                let result = resolver.resolve(&name).await;
438                (name, result)
439            }
440        });
441
442        let results = join_all(futures).await;
443
444        let mut resolved = HashMap::new();
445        for (name, result) in results {
446            match result {
447                Ok(path) => {
448                    resolved.insert(name, path);
449                }
450                Err(e) => {
451                    return Err(anyhow::anyhow!("Failed to resolve node '{}': {}", name, e));
452                }
453            }
454        }
455
456        tracing::info!("All {} framework nodes ready", resolved.len());
457        Ok(resolved)
458    }
459}
460
461impl Default for NodeResolver {
462    fn default() -> Self {
463        Self::new().expect("Failed to create NodeResolver")
464    }
465}
466
467// Inline tempdir implementation to avoid external dependency
468struct TempDir {
469    path: PathBuf,
470}
471
472impl TempDir {
473    fn path(&self) -> &std::path::Path {
474        &self.path
475    }
476}
477
478impl Drop for TempDir {
479    fn drop(&mut self) {
480        let _ = std::fs::remove_dir_all(&self.path);
481    }
482}
483
484fn tempdir() -> std::io::Result<TempDir> {
485    let mut path = std::env::temp_dir();
486    path.push(format!("mecha10-node-resolver-{}-{}", std::process::id(), rand_u64()));
487    std::fs::create_dir_all(&path)?;
488    Ok(TempDir { path })
489}
490
491fn rand_u64() -> u64 {
492    use std::time::{SystemTime, UNIX_EPOCH};
493    SystemTime::now()
494        .duration_since(UNIX_EPOCH)
495        .map(|d| d.as_nanos() as u64)
496        .unwrap_or(0)
497}
498
499#[cfg(test)]
500mod tests {
501    use super::*;
502
503    #[test]
504    fn test_is_framework_node() {
505        assert!(NodeResolver::is_framework_node("@mecha10/speaker"));
506        assert!(NodeResolver::is_framework_node("@mecha10/motor"));
507        assert!(!NodeResolver::is_framework_node("my-custom-node"));
508        assert!(!NodeResolver::is_framework_node("speaker"));
509    }
510
511    #[test]
512    fn test_short_name() {
513        assert_eq!(NodeResolver::short_name("@mecha10/speaker"), Some("speaker"));
514        assert_eq!(NodeResolver::short_name("@mecha10/motor"), Some("motor"));
515        assert_eq!(NodeResolver::short_name("my-custom-node"), None);
516    }
517
518    #[test]
519    fn test_binary_name() {
520        let resolver = NodeResolver {
521            cache_dir: PathBuf::from("/tmp"),
522            version: "0.1.44".to_string(),
523            target: "aarch64-unknown-linux-gnu".to_string(),
524        };
525
526        assert_eq!(
527            resolver.binary_name("speaker"),
528            "speaker-0.1.44-aarch64-unknown-linux-gnu"
529        );
530    }
531}