selfware 0.6.3

Your personal AI workshop — software you own, software that lasts
Documentation
//! Sandbox — Isolated Docker-based Evaluation Environments
//!
//! Each hypothesis gets its own container with resource limits.
//! Containers are ephemeral — spun up, evaluated, and destroyed.

#![allow(dead_code, unused_imports, unused_variables)]

use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::{Duration, Instant};

/// Recursively copy a directory tree, skipping any entry named `skip_name`.
fn copy_dir_recursive(src: &Path, dst: &Path, skip_name: Option<&str>) -> std::io::Result<()> {
    std::fs::create_dir_all(dst)?;
    for entry in std::fs::read_dir(src)? {
        let entry = entry?;
        let name = entry.file_name();
        let name_str = name.to_string_lossy();
        if let Some(skip) = skip_name {
            if name_str == skip {
                continue;
            }
        }
        let src_path = entry.path();
        let dst_path = dst.join(&name);
        if entry.file_type()?.is_dir() {
            copy_dir_recursive(&src_path, &dst_path, skip_name)?;
        } else {
            // Symlinks are copied as regular files pointing to target content.
            // This avoids following symlinks outside the repo root.
            if let Err(e) = std::fs::copy(&src_path, &dst_path) {
                // Best-effort: skip files we can't copy (e.g. permission issues)
                // rather than failing the entire sandbox setup.
                tracing::warn!("sandbox: skipping file {}: {}", src_path.display(), e);
            }
        }
    }
    Ok(())
}

#[derive(Debug, Clone)]
pub struct SandboxConfig {
    /// Docker image to use (should be pre-built from selfware's Dockerfile)
    pub image: String,
    /// CPU limit per container (e.g., "2" for 2 cores)
    pub cpus: String,
    /// Memory limit per container (e.g., "4g")
    pub memory: String,
    /// Maximum wall-clock time per evaluation
    pub timeout: Duration,
    /// Network access (disable for safety)
    pub network: bool,
}

impl Default for SandboxConfig {
    fn default() -> Self {
        Self {
            image: "selfware:latest".to_string(),
            cpus: "2".to_string(),
            memory: "4g".to_string(),
            timeout: Duration::from_secs(3600),
            network: false,
        }
    }
}

#[derive(Debug)]
pub struct Sandbox {
    pub container_id: String,
    pub container_name: String,
    pub config: SandboxConfig,
    pub created_at: Instant,
    /// Temporary writable copy of the repo mounted at `/workspace`.
    /// Kept alive for the sandbox's lifetime so the container can read/write it.
    /// The leading underscore silences dead-code warnings since this field
    /// exists solely to control the TempDir's lifetime.
    _workspace_tmp: Option<tempfile::TempDir>,
}

#[derive(Debug)]
pub struct SandboxResult {
    pub compiled: bool,
    pub compile_duration: Duration,
    pub tests_passed: usize,
    pub tests_total: usize,
    pub test_duration: Duration,
    pub peak_memory_bytes: u64,
    pub stdout: String,
    pub stderr: String,
    pub exit_code: i32,
}

impl Sandbox {
    /// Create and start a new sandbox container.
    ///
    /// Instead of mounting the real `repo_root` directly (which would either
    /// be read-only and prevent `git apply`, or read-write and risk corrupting
    /// the working tree), we copy the repo contents into a fresh writable
    /// temporary directory — excluding `target/` which is bind-mounted
    /// separately for cache sharing — and mount *that* copy read-write at
    /// `/workspace`.  This way `git apply` + `cargo build`/`test` operate on a
    /// throwaway copy and never touch the real repo.
    pub fn create(
        name: &str,
        repo_root: &Path,
        config: SandboxConfig,
    ) -> Result<Self, SandboxError> {
        let container_name = format!("selfware-arena-{}", name);

        // Create a fresh temp directory and recursively copy the repo into it,
        // skipping the `target` directory (it is bind-mounted separately for
        // build cache sharing and can be very large).
        let workspace_tmp = tempfile::tempdir().map_err(|e| {
            SandboxError::IoError(format!("Failed to create workspace temp dir: {}", e))
        })?;
        let workspace_path = workspace_tmp.path().to_path_buf();

        if let Err(e) = copy_dir_recursive(repo_root, &workspace_path, Some("target")) {
            // If the copy fails we cannot proceed safely — return the error.
            // The TempDir is cleaned up automatically when dropped.
            return Err(SandboxError::IoError(format!(
                "Failed to copy repo to temp workspace: {}",
                e
            )));
        }

        // Canonicalize the temp path so Docker gets a resolved absolute path.
        // On some systems tempdir returns a path with symlinks in /tmp.
        let workspace_mount = workspace_path.canonicalize().unwrap_or(workspace_path);

        let mut args = vec![
            "run".to_string(),
            "-d".to_string(),
            "--name".to_string(),
            container_name.clone(),
            format!("--cpus={}", config.cpus),
            format!("--memory={}", config.memory),
            // Mount the *temp copy* read-write so git apply can modify it.
            "-v".to_string(),
            format!("{}:/workspace:rw", workspace_mount.display()),
            // Share the real repo's target cache (read-write for build outputs).
            "-v".to_string(),
            format!("{}/target:/workspace/target", repo_root.display()),
        ];

        if !config.network {
            args.push("--network=none".to_string());
        }

        args.push(config.image.clone());
        args.push("sleep".to_string());
        args.push("infinity".to_string());

        let output = Command::new("docker")
            .args(&args)
            .output()
            .map_err(|e| SandboxError::DockerFailed(e.to_string()))?;

        if !output.status.success() {
            return Err(SandboxError::DockerFailed(
                String::from_utf8_lossy(&output.stderr).to_string(),
            ));
        }

        let container_id = String::from_utf8_lossy(&output.stdout).trim().to_string();

        Ok(Sandbox {
            container_id,
            container_name,
            config,
            created_at: Instant::now(),
            _workspace_tmp: Some(workspace_tmp),
        })
    }

    /// Execute a command inside the sandbox
    pub fn exec(&self, cmd: &str) -> Result<ExecResult, SandboxError> {
        let start = Instant::now();

        let output = Command::new("docker")
            .args(["exec", &self.container_name, "bash", "-c", cmd])
            .output()
            .map_err(|e| SandboxError::ExecFailed(e.to_string()))?;

        Ok(ExecResult {
            success: output.status.success(),
            exit_code: output.status.code().unwrap_or(-1),
            stdout: String::from_utf8_lossy(&output.stdout).to_string(),
            stderr: String::from_utf8_lossy(&output.stderr).to_string(),
            duration: start.elapsed(),
        })
    }

    /// Apply a patch to the workspace inside the container
    pub fn apply_patch(&self, patch: &str) -> Result<bool, SandboxError> {
        // Write patch to a temp file, copy into container, apply
        let patch_file = format!("/tmp/mutation-{}.patch", self.container_name);
        std::fs::write(&patch_file, patch).map_err(|e| SandboxError::IoError(e.to_string()))?;

        let _ = Command::new("docker")
            .args([
                "cp",
                &patch_file,
                &format!("{}:/tmp/mutation.patch", self.container_name),
            ])
            .output();

        let result = self.exec("cd /workspace && git apply /tmp/mutation.patch")?;
        let _ = std::fs::remove_file(&patch_file);

        Ok(result.success)
    }

    /// Run the full evaluation pipeline: compile → test → bench
    pub fn evaluate(&self) -> Result<SandboxResult, SandboxError> {
        // Step 1: Compile
        let compile = self.exec("cd /workspace && cargo build --release 2>&1")?;
        if !compile.success {
            return Ok(SandboxResult {
                compiled: false,
                compile_duration: compile.duration,
                tests_passed: 0,
                tests_total: 0,
                test_duration: Duration::ZERO,
                peak_memory_bytes: 0,
                stdout: compile.stdout,
                stderr: compile.stderr,
                exit_code: compile.exit_code,
            });
        }

        // Step 2: Run tests — capture full output (no `tail` pipe which can
        // SIGPIPE and drop the `test result:` line).
        let test = self.exec("cd /workspace && cargo test --all-features 2>&1")?;

        let (passed, total) = parse_test_counts(&test.stdout);

        // Step 3: Get memory stats
        let stats = self.get_stats()?;

        Ok(SandboxResult {
            compiled: true,
            compile_duration: compile.duration,
            tests_passed: passed,
            tests_total: total,
            test_duration: test.duration,
            peak_memory_bytes: stats.peak_memory_bytes,
            stdout: format!("{}\n---\n{}", compile.stdout, test.stdout),
            stderr: format!("{}\n---\n{}", compile.stderr, test.stderr),
            exit_code: test.exit_code,
        })
    }

    /// Get container resource usage stats
    fn get_stats(&self) -> Result<ContainerStats, SandboxError> {
        let output = Command::new("docker")
            .args([
                "stats",
                "--no-stream",
                "--format",
                "{{.MemUsage}}",
                &self.container_name,
            ])
            .output()
            .map_err(|e| SandboxError::DockerFailed(e.to_string()))?;

        let mem_str = String::from_utf8_lossy(&output.stdout).trim().to_string();
        let peak = parse_memory_string(&mem_str);

        Ok(ContainerStats {
            peak_memory_bytes: peak,
        })
    }

    /// Destroy the sandbox container
    pub fn destroy(self) -> Result<(), SandboxError> {
        let _ = Command::new("docker")
            .args(["rm", "-f", &self.container_name])
            .output();
        Ok(())
    }

    /// Check if the sandbox has exceeded its timeout
    pub fn is_expired(&self) -> bool {
        self.created_at.elapsed() > self.config.timeout
    }
}

#[derive(Debug)]
pub struct ExecResult {
    pub success: bool,
    pub exit_code: i32,
    pub stdout: String,
    pub stderr: String,
    pub duration: Duration,
}

#[derive(Debug)]
struct ContainerStats {
    peak_memory_bytes: u64,
}

fn parse_test_counts(output: &str) -> (usize, usize) {
    // Parse "test result: ok. X passed; Y failed; Z ignored"
    for line in output.lines().rev() {
        if line.contains("test result:") {
            let mut passed = 0;
            let mut failed = 0;
            let mut ignored = 0;

            for part in line.split(';') {
                let part = part.trim();
                if part.contains("passed") {
                    passed = part
                        .split_whitespace()
                        .filter_map(|w| w.parse().ok())
                        .next()
                        .unwrap_or(0);
                } else if part.contains("failed") {
                    failed = part
                        .split_whitespace()
                        .filter_map(|w| w.parse().ok())
                        .next()
                        .unwrap_or(0);
                } else if part.contains("ignored") {
                    ignored = part
                        .split_whitespace()
                        .filter_map(|w| w.parse().ok())
                        .next()
                        .unwrap_or(0);
                }
            }

            return (passed, passed + failed + ignored);
        }
    }
    (0, 0)
}

fn parse_memory_string(mem: &str) -> u64 {
    // Docker stats format: "123.4MiB / 4GiB"
    let usage = mem.split('/').next().unwrap_or("0").trim();
    if usage.contains("GiB") {
        let n: f64 = usage.replace("GiB", "").trim().parse().unwrap_or(0.0);
        (n * 1024.0 * 1024.0 * 1024.0) as u64
    } else if usage.contains("MiB") {
        let n: f64 = usage.replace("MiB", "").trim().parse().unwrap_or(0.0);
        (n * 1024.0 * 1024.0) as u64
    } else if usage.contains("KiB") {
        let n: f64 = usage.replace("KiB", "").trim().parse().unwrap_or(0.0);
        (n * 1024.0) as u64
    } else {
        0
    }
}

#[derive(Debug)]
pub enum SandboxError {
    DockerFailed(String),
    ExecFailed(String),
    IoError(String),
    Timeout,
}

impl std::fmt::Display for SandboxError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::DockerFailed(msg) => write!(f, "Docker operation failed: {}", msg),
            Self::ExecFailed(msg) => write!(f, "Container exec failed: {}", msg),
            Self::IoError(msg) => write!(f, "IO error: {}", msg),
            Self::Timeout => write!(f, "Sandbox evaluation timed out"),
        }
    }
}

impl std::error::Error for SandboxError {}

#[cfg(test)]
#[path = "../../tests/unit/evolution/sandbox/sandbox_test.rs"]
mod tests;