imp-core 0.2.0

Agent engine for imp: loop, tools, sessions, hooks, context, and SDK
Documentation
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::time::{Duration, Instant};

use tokio::io::AsyncReadExt;
use tokio::process::Command;

use super::{
    VerificationArtifactRef, VerificationCommand, VerificationGate, VerificationGateKind,
    VerificationGateResult,
};
use crate::error::{Error, Result};

const DEFAULT_TIMEOUT: Duration = Duration::from_secs(120);
const MAX_CAPTURE_BYTES: usize = 64 * 1024;

#[derive(Debug, Clone)]
pub struct VerificationGateRunner {
    cwd: PathBuf,
    artifact_root: PathBuf,
    default_timeout: Duration,
    max_capture_bytes: usize,
}

impl VerificationGateRunner {
    pub fn new(cwd: impl Into<PathBuf>, artifact_root: impl Into<PathBuf>) -> Self {
        Self {
            cwd: cwd.into(),
            artifact_root: artifact_root.into(),
            default_timeout: DEFAULT_TIMEOUT,
            max_capture_bytes: MAX_CAPTURE_BYTES,
        }
    }

    pub fn with_default_timeout(mut self, timeout: Duration) -> Self {
        self.default_timeout = timeout;
        self
    }

    pub fn with_max_capture_bytes(mut self, bytes: usize) -> Self {
        self.max_capture_bytes = bytes;
        self
    }

    pub async fn run(&self, gate: &mut VerificationGate) -> Result<VerificationGateResult> {
        let Some(command) = gate.command.clone() else {
            gate.mark_blocked("verification gate has no command");
            return Ok(VerificationGateResult {
                summary: Some("verification gate has no command".into()),
                ..VerificationGateResult::default()
            });
        };
        if gate.kind != VerificationGateKind::Command {
            gate.mark_blocked("only command verification gates are executable today");
            return Ok(VerificationGateResult {
                summary: Some("only command verification gates are executable today".into()),
                ..VerificationGateResult::default()
            });
        }

        gate.mark_running();
        let started = Instant::now();
        let cwd = command.cwd.clone().unwrap_or_else(|| self.cwd.clone());
        let timeout = command.timeout.unwrap_or(self.default_timeout);
        let gate_dir = self.artifact_root.join(sanitize_path_segment(&gate.id));
        tokio::fs::create_dir_all(&gate_dir)
            .await
            .map_err(Error::Io)?;

        let mut child = Command::new("/bin/sh")
            .arg("-lc")
            .arg(&command.command)
            .current_dir(&cwd)
            .stdin(Stdio::null())
            .stdout(Stdio::piped())
            .stderr(Stdio::piped())
            .kill_on_drop(true)
            .spawn()
            .map_err(Error::Io)?;

        let mut stdout = child.stdout.take().expect("stdout piped");
        let mut stderr = child.stderr.take().expect("stderr piped");
        let stdout_task = tokio::spawn(async move {
            let mut bytes = Vec::new();
            stdout.read_to_end(&mut bytes).await.map(|_| bytes)
        });
        let stderr_task = tokio::spawn(async move {
            let mut bytes = Vec::new();
            stderr.read_to_end(&mut bytes).await.map(|_| bytes)
        });

        let status = match tokio::time::timeout(timeout, child.wait()).await {
            Ok(wait) => wait.map_err(Error::Io)?,
            Err(_) => {
                let _ = child.kill().await;
                let stdout_bytes = join_output(stdout_task).await;
                let stderr_bytes = join_output(stderr_task).await;
                let result = self
                    .write_artifacts(
                        gate,
                        &command,
                        &cwd,
                        started.elapsed(),
                        None,
                        stdout_bytes,
                        stderr_bytes,
                        &gate_dir,
                        Some(format!(
                            "verification command timed out after {}ms",
                            timeout.as_millis()
                        )),
                    )
                    .await?;
                gate.mark_blocked(
                    result
                        .summary
                        .clone()
                        .unwrap_or_else(|| "verification command timed out".into()),
                );
                return Ok(result);
            }
        };

        let stdout_bytes = join_output(stdout_task).await;
        let stderr_bytes = join_output(stderr_task).await;
        let exit_code = status.code();
        let result = self
            .write_artifacts(
                gate,
                &command,
                &cwd,
                started.elapsed(),
                exit_code,
                stdout_bytes,
                stderr_bytes,
                &gate_dir,
                None,
            )
            .await?;

        match exit_code {
            Some(0) => gate.mark_passed(result.clone()),
            _ => gate.mark_failed(result.clone()),
        }
        Ok(result)
    }

    #[allow(clippy::too_many_arguments)]
    async fn write_artifacts(
        &self,
        gate: &mut VerificationGate,
        command: &VerificationCommand,
        cwd: &Path,
        elapsed: Duration,
        exit_code: Option<i32>,
        stdout_bytes: Vec<u8>,
        stderr_bytes: Vec<u8>,
        gate_dir: &Path,
        blocked_summary: Option<String>,
    ) -> Result<VerificationGateResult> {
        let stdout_capture = CapturedOutput::new(stdout_bytes, self.max_capture_bytes);
        let stderr_capture = CapturedOutput::new(stderr_bytes, self.max_capture_bytes);
        let stdout_path = gate_dir.join("stdout.log");
        let stderr_path = gate_dir.join("stderr.log");
        let status_path = gate_dir.join("status.json");

        tokio::fs::write(&stdout_path, stdout_capture.content.as_bytes())
            .await
            .map_err(Error::Io)?;
        tokio::fs::write(&stderr_path, stderr_capture.content.as_bytes())
            .await
            .map_err(Error::Io)?;

        let summary = blocked_summary.unwrap_or_else(|| match exit_code {
            Some(0) => "verification command passed".to_string(),
            Some(code) => format!("verification command failed with exit code {code}"),
            None => "verification command terminated without exit code".to_string(),
        });

        let result = VerificationGateResult {
            exit_code,
            duration_ms: Some(elapsed.as_millis() as u64),
            summary: Some(summary),
            stdout_summary: Some(stdout_capture.summary()),
            stderr_summary: Some(stderr_capture.summary()),
        };

        let status_json = serde_json::json!({
            "gate_id": gate.id,
            "command": command.command,
            "cwd": cwd,
            "exit_code": exit_code,
            "duration_ms": result.duration_ms,
            "summary": result.summary,
            "stdout_truncated": stdout_capture.truncated,
            "stderr_truncated": stderr_capture.truncated,
        });
        tokio::fs::write(
            &status_path,
            serde_json::to_vec_pretty(&status_json).map_err(Error::Json)?,
        )
        .await
        .map_err(Error::Io)?;

        gate.artifacts = vec![
            artifact_ref(
                "stdout",
                stdout_path,
                stdout_capture.original_len,
                stdout_capture.truncated,
            ),
            artifact_ref(
                "stderr",
                stderr_path,
                stderr_capture.original_len,
                stderr_capture.truncated,
            ),
            artifact_ref("status", status_path, None, false),
        ];
        Ok(result)
    }
}

fn artifact_ref(
    kind: &str,
    path: PathBuf,
    bytes: Option<usize>,
    truncated: bool,
) -> VerificationArtifactRef {
    let mut artifact = VerificationArtifactRef::new(kind, path);
    artifact.bytes = bytes.map(|bytes| bytes as u64);
    if truncated {
        artifact.redaction = Some("output truncated".into());
    }
    artifact
}

async fn join_output(task: tokio::task::JoinHandle<std::io::Result<Vec<u8>>>) -> Vec<u8> {
    match task.await {
        Ok(Ok(bytes)) => bytes,
        _ => Vec::new(),
    }
}

fn sanitize_path_segment(input: &str) -> String {
    let sanitized: String = input
        .chars()
        .map(|ch| {
            if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
                ch
            } else {
                '-'
            }
        })
        .collect();
    if sanitized.is_empty() {
        "gate".into()
    } else {
        sanitized
    }
}

struct CapturedOutput {
    content: String,
    original_len: Option<usize>,
    truncated: bool,
}

impl CapturedOutput {
    fn new(bytes: Vec<u8>, max_bytes: usize) -> Self {
        let original_len = bytes.len();
        let truncated = original_len > max_bytes;
        let slice = if truncated {
            &bytes[..max_bytes]
        } else {
            &bytes[..]
        };
        let mut content = String::from_utf8_lossy(slice).to_string();
        if truncated {
            content.push_str("\n[verification output truncated]\n");
        }
        Self {
            content,
            original_len: Some(original_len),
            truncated,
        }
    }

    fn summary(&self) -> String {
        let trimmed = self.content.trim();
        if trimmed.is_empty() {
            return "<empty>".into();
        }
        let mut summary: String = trimmed.chars().take(500).collect();
        if trimmed.chars().count() > 500 {
            summary.push('…');
        }
        summary
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::workflow::VerificationGateStatus;

    #[tokio::test]
    async fn command_gate_runner_passes_and_writes_artifacts() {
        let temp = tempfile::TempDir::new().unwrap();
        let runner = VerificationGateRunner::new(temp.path(), temp.path().join("artifacts"));
        let mut gate = VerificationGate::command("pass", "printf 'hello' && printf 'warn' >&2");

        let result = runner.run(&mut gate).await.unwrap();

        assert_eq!(gate.status, VerificationGateStatus::Passed);
        assert_eq!(result.exit_code, Some(0));
        assert_eq!(result.stdout_summary.as_deref(), Some("hello"));
        assert_eq!(result.stderr_summary.as_deref(), Some("warn"));
        assert!(gate
            .artifacts
            .iter()
            .any(|artifact| artifact.kind == "stdout"));
        assert_eq!(
            std::fs::read_to_string(temp.path().join("artifacts/pass/stdout.log")).unwrap(),
            "hello"
        );
        assert!(temp.path().join("artifacts/pass/status.json").exists());
    }

    #[tokio::test]
    async fn command_gate_runner_marks_failed_command() {
        let temp = tempfile::TempDir::new().unwrap();
        let runner = VerificationGateRunner::new(temp.path(), temp.path().join("artifacts"));
        let mut gate = VerificationGate::command("fail", "printf 'bad' >&2; exit 7");

        let result = runner.run(&mut gate).await.unwrap();

        assert_eq!(gate.status, VerificationGateStatus::Failed);
        assert_eq!(result.exit_code, Some(7));
        assert!(result.summary.unwrap().contains("exit code 7"));
        assert_eq!(
            std::fs::read_to_string(temp.path().join("artifacts/fail/stderr.log")).unwrap(),
            "bad"
        );
    }

    #[tokio::test]
    async fn command_gate_runner_marks_timeout_blocked() {
        let temp = tempfile::TempDir::new().unwrap();
        let runner = VerificationGateRunner::new(temp.path(), temp.path().join("artifacts"))
            .with_default_timeout(Duration::from_millis(50));
        let mut gate = VerificationGate::command("timeout", "sleep 2");

        let result = runner.run(&mut gate).await.unwrap();

        assert_eq!(gate.status, VerificationGateStatus::Blocked);
        assert_eq!(result.exit_code, None);
        assert!(result.summary.unwrap().contains("timed out"));
        assert!(temp.path().join("artifacts/timeout/status.json").exists());
    }

    #[tokio::test]
    async fn command_gate_runner_truncates_large_output() {
        let temp = tempfile::TempDir::new().unwrap();
        let runner = VerificationGateRunner::new(temp.path(), temp.path().join("artifacts"))
            .with_max_capture_bytes(5);
        let mut gate = VerificationGate::command("truncate", "printf 'abcdefghijklmnopqrstuvwxyz'");

        let result = runner.run(&mut gate).await.unwrap();

        assert_eq!(gate.status, VerificationGateStatus::Passed);
        assert!(result.stdout_summary.unwrap().contains("truncated"));
        let stdout =
            std::fs::read_to_string(temp.path().join("artifacts/truncate/stdout.log")).unwrap();
        assert!(stdout.starts_with("abcde"));
        assert!(stdout.contains("truncated"));
        assert!(gate
            .artifacts
            .iter()
            .any(|artifact| artifact.kind == "stdout"
                && artifact.redaction.as_deref() == Some("output truncated")));
    }
}