a3s-runtime 0.2.0

Provider-neutral execution contract and client for A3S runtimes
Documentation
use super::{RuntimeObservation, RuntimeUnitSpec};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RuntimeApplyRequest {
    pub schema: String,
    pub request_id: String,
    pub deadline_at_ms: Option<u64>,
    pub spec: RuntimeUnitSpec,
}

impl RuntimeApplyRequest {
    pub const SCHEMA: &'static str = "a3s.runtime.apply-request.v1";

    pub fn validate(&self) -> Result<(), String> {
        if self.schema != Self::SCHEMA {
            return Err(format!(
                "unsupported Runtime apply schema {:?}",
                self.schema
            ));
        }
        super::validate_id("request_id", &self.request_id, 512)?;
        if self.deadline_at_ms == Some(0) {
            return Err("deadline_at_ms must be positive when present".into());
        }
        self.spec.validate()
    }

    pub fn digest(&self) -> Result<String, String> {
        canonical_digest(self, self.validate())
    }
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RuntimeActionRequest {
    pub schema: String,
    pub request_id: String,
    pub unit_id: String,
    pub generation: u64,
    pub deadline_at_ms: Option<u64>,
}

impl RuntimeActionRequest {
    pub const SCHEMA: &'static str = "a3s.runtime.action-request.v1";

    pub fn validate(&self) -> Result<(), String> {
        if self.schema != Self::SCHEMA {
            return Err(format!(
                "unsupported Runtime action schema {:?}",
                self.schema
            ));
        }
        super::validate_id("request_id", &self.request_id, 512)?;
        super::validate_id("unit_id", &self.unit_id, 512)?;
        if self.generation == 0 || self.deadline_at_ms == Some(0) {
            return Err("action generation and deadline must be positive".into());
        }
        Ok(())
    }

    pub fn digest(&self) -> Result<String, String> {
        canonical_digest(self, self.validate())
    }
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RuntimeRemoval {
    pub schema: String,
    pub request_id: String,
    pub unit_id: String,
    pub generation: u64,
    pub removed_at_ms: u64,
    pub already_absent: bool,
}

impl RuntimeRemoval {
    pub const SCHEMA: &'static str = "a3s.runtime.removal.v1";

    pub fn validate(&self) -> Result<(), String> {
        if self.schema != Self::SCHEMA {
            return Err(format!(
                "unsupported Runtime removal schema {:?}",
                self.schema
            ));
        }
        super::validate_id("request_id", &self.request_id, 512)?;
        super::validate_id("unit_id", &self.unit_id, 512)?;
        if self.generation == 0 {
            return Err("removal generation must be positive".into());
        }
        Ok(())
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RuntimeLogStream {
    Stdout,
    Stderr,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RuntimeLogQuery {
    pub schema: String,
    pub unit_id: String,
    pub generation: u64,
    pub cursor: Option<String>,
    pub limit: u32,
    pub stream: Option<RuntimeLogStream>,
}

impl RuntimeLogQuery {
    pub const SCHEMA: &'static str = "a3s.runtime.log-query.v1";

    pub fn validate(&self) -> Result<(), String> {
        if self.schema != Self::SCHEMA {
            return Err(format!(
                "unsupported Runtime log query schema {:?}",
                self.schema
            ));
        }
        super::validate_id("unit_id", &self.unit_id, 512)?;
        if self.generation == 0 || self.limit == 0 || self.limit > 10_000 {
            return Err("log generation or limit is invalid".into());
        }
        if self
            .cursor
            .as_ref()
            .is_some_and(|value| value.is_empty() || value.len() > 1024 || value.contains('\0'))
        {
            return Err("log cursor is invalid".into());
        }
        Ok(())
    }
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RuntimeLogChunk {
    pub schema: String,
    pub cursor: String,
    pub sequence: u64,
    pub observed_at_ms: u64,
    pub stream: RuntimeLogStream,
    pub data: String,
}

impl RuntimeLogChunk {
    pub const SCHEMA: &'static str = "a3s.runtime.log-chunk.v1";

    pub fn validate(&self) -> Result<(), String> {
        if self.schema != Self::SCHEMA {
            return Err(format!(
                "unsupported Runtime log chunk schema {:?}",
                self.schema
            ));
        }
        super::validate_nonempty("log cursor", &self.cursor, 1024)?;
        if self.data.len() > 1024 * 1024 {
            return Err("log chunk exceeds one MiB".into());
        }
        Ok(())
    }
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RuntimeExecRequest {
    pub schema: String,
    pub request_id: String,
    pub unit_id: String,
    pub generation: u64,
    pub command: Vec<String>,
    pub timeout_ms: u64,
    pub deadline_at_ms: Option<u64>,
}

impl RuntimeExecRequest {
    pub const SCHEMA: &'static str = "a3s.runtime.exec-request.v1";

    pub fn validate(&self) -> Result<(), String> {
        if self.schema != Self::SCHEMA {
            return Err(format!(
                "unsupported Runtime exec request schema {:?}",
                self.schema
            ));
        }
        super::validate_id("request_id", &self.request_id, 512)?;
        super::validate_id("unit_id", &self.unit_id, 512)?;
        if self.generation == 0
            || self.timeout_ms == 0
            || self.deadline_at_ms == Some(0)
            || self.command.is_empty()
            || self.command.len() > 256
            || self
                .command
                .iter()
                .any(|value| value.is_empty() || value.len() > 32 * 1024 || value.contains('\0'))
        {
            return Err("exec request is invalid".into());
        }
        Ok(())
    }

    pub fn digest(&self) -> Result<String, String> {
        canonical_digest(self, self.validate())
    }
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RuntimeExecResult {
    pub schema: String,
    pub request_id: String,
    pub observation: RuntimeObservation,
    pub exit_code: i32,
    pub stdout: String,
    pub stderr: String,
    pub truncated: bool,
}

impl RuntimeExecResult {
    pub const SCHEMA: &'static str = "a3s.runtime.exec-result.v1";

    pub fn validate(&self) -> Result<(), String> {
        if self.schema != Self::SCHEMA {
            return Err(format!(
                "unsupported Runtime exec result schema {:?}",
                self.schema
            ));
        }
        super::validate_id("request_id", &self.request_id, 512)?;
        self.observation.validate()?;
        if self.stdout.len() > 16 * 1024 * 1024 || self.stderr.len() > 16 * 1024 * 1024 {
            return Err("exec output exceeds protocol limits".into());
        }
        Ok(())
    }
}

fn canonical_digest<T: Serialize>(value: &T, valid: Result<(), String>) -> Result<String, String> {
    valid?;
    let bytes = serde_json::to_vec(value)
        .map_err(|error| format!("could not encode Runtime request: {error}"))?;
    Ok(format!("sha256:{:x}", Sha256::digest(bytes)))
}