bijux-dag-runtime 0.4.1

Execution engine, replay semantics, and runtime policy layer for Bijux DAG graphs.
Documentation
use crate::NodeStatus;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum BackendKind {
    Shell,
    Process,
    Container,
    RemoteFuture,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct BackendCapabilities {
    pub kind: BackendKind,
    pub supports_env_shaping: bool,
    pub supports_timeout: bool,
    pub supports_stream_capture: bool,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ExecutionBackendCapabilityDescriptor {
    pub backend_name: String,
    pub kind: BackendKind,
    pub supports_env_shaping: bool,
    pub supports_timeout: bool,
    pub supports_stream_capture: bool,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct BackendBindingRequest {
    pub node_id: String,
    pub required_kind: BackendKind,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ExecutionAttemptRecord {
    pub node_id: String,
    pub attempt: u32,
    pub backend_kind: BackendKind,
    pub status: NodeStatus,
    pub exit_code: Option<i32>,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct BackendContext {
    pub node_id: String,
    pub attempt: u32,
    pub args: Vec<String>,
    pub env: BTreeMap<String, String>,
    pub declared_outputs: BTreeSet<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct BackendLifecycleResult {
    pub status: NodeStatus,
    pub exit_code: Option<i32>,
    pub stdout: String,
    pub stderr: String,
    pub produced_outputs: BTreeSet<String>,
}

#[derive(Debug, thiserror::Error)]
pub enum BackendError {
    #[error("backend capability mismatch: {0}")]
    Capability(String),
    #[error("backend prepare failed: {0}")]
    Prepare(String),
    #[error("backend launch failed: {0}")]
    Launch(String),
    #[error("backend observe failed: {0}")]
    Observe(String),
    #[error("backend observe timed out: {0}")]
    ObserveTimeout(String),
    #[error("backend finalize failed: {0}")]
    Finalize(String),
    #[error("backend cleanup failed: {0}")]
    Cleanup(String),
}

pub trait ExecutionBackend: Send + Sync {
    fn name(&self) -> &'static str;
    fn capabilities(&self) -> BackendCapabilities;
    fn prepare(&self, _ctx: &BackendContext) -> Result<(), BackendError>;
    fn launch(&self, _ctx: &BackendContext) -> Result<(), BackendError>;
    fn observe(&self, _ctx: &BackendContext) -> Result<BackendLifecycleResult, BackendError>;
    fn finalize(
        &self,
        _ctx: &BackendContext,
        _result: &BackendLifecycleResult,
    ) -> Result<(), BackendError>;
    fn cleanup(&self, _ctx: &BackendContext) -> Result<(), BackendError>;
}

pub fn bind_backend_or_error(
    request: &BackendBindingRequest,
    backend: &dyn ExecutionBackend,
) -> Result<(), BackendError> {
    let caps = backend.capabilities();
    if caps.kind == request.required_kind {
        Ok(())
    } else {
        Err(BackendError::Capability(format!(
            "node '{}' requires {:?}, backend '{}' exposes {:?}",
            request.node_id,
            request.required_kind,
            backend.name(),
            caps.kind
        )))
    }
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct EngineOutcome {
    pub attempts: Vec<ExecutionAttemptRecord>,
}

pub fn execute_with_backend(
    backend: &dyn ExecutionBackend,
    required_kind: BackendKind,
    nodes: &[String],
) -> Result<EngineOutcome, BackendError> {
    let mut attempts = Vec::new();
    for node_id in nodes {
        bind_backend_or_error(
            &BackendBindingRequest {
                node_id: node_id.clone(),
                required_kind: required_kind.clone(),
            },
            backend,
        )?;
        let ctx = BackendContext {
            node_id: node_id.clone(),
            attempt: 1,
            args: vec!["echo".to_string(), node_id.clone()],
            env: BTreeMap::new(),
            declared_outputs: BTreeSet::new(),
        };
        let lifecycle = (|| -> Result<BackendLifecycleResult, BackendError> {
            backend.prepare(&ctx)?;
            backend.launch(&ctx)?;
            let observed = backend.observe(&ctx)?;
            if !observed.produced_outputs.iter().all(|entry| ctx.declared_outputs.contains(entry)) {
                return Err(BackendError::Finalize(format!(
                    "backend produced undeclared outputs for {}",
                    ctx.node_id
                )));
            }
            backend.finalize(&ctx, &observed)?;
            Ok(observed)
        })();
        let cleanup = backend.cleanup(&ctx);
        let observed = match (lifecycle, cleanup) {
            (Err(primary), _) => return Err(primary),
            (Ok(_), Err(cleanup_error)) => return Err(cleanup_error),
            (Ok(observed), Ok(())) => observed,
        };
        attempts.push(ExecutionAttemptRecord {
            node_id: node_id.clone(),
            attempt: 1,
            backend_kind: backend.capabilities().kind,
            status: observed.status,
            exit_code: observed.exit_code,
        });
    }
    Ok(EngineOutcome { attempts })
}

pub fn backend_registry() -> Vec<ExecutionBackendCapabilityDescriptor> {
    let fake = FakeBackend::default();
    let process_like = ProcessLikeBackend;
    let backends: [&dyn ExecutionBackend; 2] = [&fake, &process_like];
    backends
        .iter()
        .map(|backend| {
            let caps = backend.capabilities();
            ExecutionBackendCapabilityDescriptor {
                backend_name: backend.name().to_string(),
                kind: caps.kind,
                supports_env_shaping: caps.supports_env_shaping,
                supports_timeout: caps.supports_timeout,
                supports_stream_capture: caps.supports_stream_capture,
            }
        })
        .collect()
}

#[derive(Default)]
pub struct FakeBackend {
    pub fail_prepare_for: BTreeSet<String>,
    pub fail_launch_for: BTreeSet<String>,
    pub fail_observe_timeout_for: BTreeSet<String>,
    pub fail_finalize_for: BTreeSet<String>,
    pub fail_cleanup_for: BTreeSet<String>,
}

impl ExecutionBackend for FakeBackend {
    fn name(&self) -> &'static str {
        "fake-backend"
    }

    fn capabilities(&self) -> BackendCapabilities {
        BackendCapabilities {
            kind: BackendKind::Shell,
            supports_env_shaping: true,
            supports_timeout: true,
            supports_stream_capture: true,
        }
    }

    fn prepare(&self, ctx: &BackendContext) -> Result<(), BackendError> {
        if self.fail_prepare_for.contains(&ctx.node_id) {
            return Err(BackendError::Prepare(format!("prepare failed for {}", ctx.node_id)));
        }
        Ok(())
    }

    fn launch(&self, ctx: &BackendContext) -> Result<(), BackendError> {
        if self.fail_launch_for.contains(&ctx.node_id) {
            return Err(BackendError::Launch(format!("launch failed for {}", ctx.node_id)));
        }
        Ok(())
    }

    fn observe(&self, ctx: &BackendContext) -> Result<BackendLifecycleResult, BackendError> {
        if self.fail_observe_timeout_for.contains(&ctx.node_id) {
            return Err(BackendError::ObserveTimeout(format!(
                "observe timeout for {}",
                ctx.node_id
            )));
        }
        Ok(BackendLifecycleResult {
            status: NodeStatus::Success,
            exit_code: Some(0),
            stdout: String::new(),
            stderr: String::new(),
            produced_outputs: BTreeSet::new(),
        })
    }

    fn finalize(
        &self,
        ctx: &BackendContext,
        _result: &BackendLifecycleResult,
    ) -> Result<(), BackendError> {
        if self.fail_finalize_for.contains(&ctx.node_id) {
            return Err(BackendError::Finalize(format!("finalize failed for {}", ctx.node_id)));
        }
        Ok(())
    }

    fn cleanup(&self, ctx: &BackendContext) -> Result<(), BackendError> {
        if self.fail_cleanup_for.contains(&ctx.node_id) {
            return Err(BackendError::Cleanup(format!("cleanup failed for {}", ctx.node_id)));
        }
        Ok(())
    }
}

pub struct ProcessLikeBackend;

impl ExecutionBackend for ProcessLikeBackend {
    fn name(&self) -> &'static str {
        "process-like-backend"
    }

    fn capabilities(&self) -> BackendCapabilities {
        BackendCapabilities {
            kind: BackendKind::Shell,
            supports_env_shaping: true,
            supports_timeout: true,
            supports_stream_capture: true,
        }
    }

    fn prepare(&self, _ctx: &BackendContext) -> Result<(), BackendError> {
        Ok(())
    }

    fn launch(&self, _ctx: &BackendContext) -> Result<(), BackendError> {
        Ok(())
    }

    fn observe(&self, _ctx: &BackendContext) -> Result<BackendLifecycleResult, BackendError> {
        Ok(BackendLifecycleResult {
            status: NodeStatus::Success,
            exit_code: Some(0),
            stdout: String::new(),
            stderr: String::new(),
            produced_outputs: BTreeSet::new(),
        })
    }

    fn finalize(
        &self,
        _ctx: &BackendContext,
        _result: &BackendLifecycleResult,
    ) -> Result<(), BackendError> {
        Ok(())
    }

    fn cleanup(&self, _ctx: &BackendContext) -> Result<(), BackendError> {
        Ok(())
    }
}