248 lines · 10.0 KB · Rust
RawDownload
//! Error taxonomy for the Agent OS client SDK.//!//! Mirrors `spec.md` §4 / ADR-001 §4. Preserves the TypeScript SDK distinction so callers can still//! discriminate path-guard violations from kernel errno failures. Public methods return//! [`anyhow::Result`]; the typed [`ClientError`] is carried as the `source` so callers can downcast.//!//! Durable session operations return typed client errors when the sidecar//! rejects an operation. ACP adapter JSON-RPC details are normalized by the//! sidecar and are not exposed as a second raw session API. use agentos_sidecar_client::{ProtocolCodecError, TransportError}; /// Structured sidecar admission metadata kept behind one allocation so the/// public [`ClientError`] remains cheap to return through every SDK method.#[derive(Debug)]pub struct ResourceLimitDetails {    pub limit_name: Option<String>,    pub configured_limit: Option<u64>,    pub current_usage: Option<u64>,    pub requested: Option<u64>,    pub unit: Option<String>,    pub scope: Option<String>,    pub vm_id: Option<String>,    pub session_generation: Option<u64>,    pub capability_id: Option<u64>,    pub operation: Option<String>,    pub configuration_path: Option<String>,    pub retryable: Option<bool>,    pub errno: Option<String>,} /// Typed error taxonomy for the client SDK.#[derive(thiserror::Error, Debug)]pub enum ClientError {    /// A filesystem path was not absolute (did not start with `/`).    ///    /// The message text matches the TypeScript `AgentOs` exactly (capital "P"). These strings are    /// observable data (they surface in `BatchWriteResult.error` / `BatchReadResult.error`), not    /// logs, so the casing follows TS rather than the lowercase log convention.    #[error("Path must be absolute: {0}")]    PathNotAbsolute(String),     /// A filesystem path was not in posix-normalized form.    ///    /// The message text matches the TypeScript `AgentOs` exactly (capital "P").    #[error("Path must be normalized: {0}")]    PathNotNormalized(String),     /// A write was attempted against a read-only path (for example `/proc`).    ///    /// The message text matches the TypeScript `AgentOs` exactly (capital "P").    #[error("Path is read-only: {0}")]    PathReadOnly(String),     /// An SDK-spawned process with the given pid was not found.    ///    /// The message text matches the TypeScript `AgentOs` exactly (capital "P"). These strings are    /// observable data (surfaced to callers), not logs, so the casing follows TS rather than the    /// lowercase log convention.    #[error("Process not found: {0}")]    ProcessNotFound(u32),     /// A shell with the given synthetic `shell-N` id was not found.    #[error("shell not found: {0}")]    ShellNotFound(String),     /// An ACP session with the given id was not found.    #[error("session not found: {0}")]    SessionNotFound(String),     /// A kernel/sidecar operation failed. The errno `code` string (`ENOENT`, `EEXIST`, `ENOTDIR`,    /// `EACCES`, `EISDIR`, `ENOTEMPTY`, ...) is preserved verbatim for parity with the TypeScript    /// `KernelError`.    #[error("kernel error [{code}]: {message}")]    Kernel { code: String, message: String },     /// A sidecar policy/admission bound rejected an operation. Fields are    /// copied from the lockstep wire response and never parsed from text.    #[error("resource limit [{code}]: {message}")]    ResourceLimit {        code: String,        message: String,        details: Box<ResourceLimitDetails>,    },     /// A durable ACP/session operation was rejected by the sidecar. The stable    /// wire code remains separately inspectable, matching the TypeScript    /// client's `Error & { code?: string }` surface.    #[error("ACP operation [{code}]: {message}")]    AcpOperation { code: String, message: String },     /// A cron schedule string could not be parsed/validated.    #[error("invalid schedule: {0}")]    InvalidSchedule(String),     /// A one-shot (ISO-8601) cron schedule resolved to a time in the past.    #[error("schedule is in the past: {0}")]    PastSchedule(String),     /// A framing/codec failure on the sidecar transport.    #[error("transport error: {0}")]    Transport(#[from] ProtocolCodecError),     /// A generic sidecar rejection or I/O failure with context.    #[error("sidecar error: {0}")]    Sidecar(String),} impl From<TransportError> for ClientError {    fn from(error: TransportError) -> Self {        match error {            TransportError::Protocol(error) => ClientError::Transport(error),            TransportError::Sidecar(message) => ClientError::Sidecar(message),        }    }} impl ClientError {    pub(crate) fn from_rejection(        rejection: agentos_sidecar_client::wire::RejectedResponse,    ) -> Self {        if rejection.code == "ERR_AGENTOS_RESOURCE_LIMIT"            || rejection.code == "ERR_AGENTOS_OVERLOADED"        {            return Self::ResourceLimit {                code: rejection.code,                message: rejection.message,                details: Box::new(ResourceLimitDetails {                    limit_name: rejection.limit_name,                    configured_limit: rejection.configured_limit,                    current_usage: rejection.current_usage,                    requested: rejection.requested,                    unit: rejection.unit,                    scope: rejection.scope,                    vm_id: rejection.vm_id,                    session_generation: rejection.session_generation,                    capability_id: rejection.capability_id,                    operation: rejection.operation,                    configuration_path: rejection.configuration_path,                    retryable: rejection.retryable,                    errno: rejection.errno,                }),            };        }        Self::Kernel {            code: rejection.code,            message: rejection.message,        }    }     /// Render this error the way the TypeScript `AgentOs` surfaces `err.message` into batch results    /// (`BatchWriteResult.error` / `BatchReadResult.error`).    ///    /// The general [`Display`](std::fmt::Display) impl carries a human/log-oriented prefix    /// (`kernel error [<code>]: ...`), but the batch surface is observable data that must match TS    /// byte-for-byte. For kernel failures TS reports `KernelError.message`, which is    /// `<code>: <message>` (and avoids doubling the code when the message already starts with it).    /// Path-guard variants already carry the exact TS strings via their `Display` impl.    pub fn batch_message(&self) -> String {        match self {            ClientError::Kernel { code, message } => {                if message.starts_with(&format!("{code}:")) {                    message.clone()                } else {                    format!("{code}: {message}")                }            }            ClientError::ResourceLimit { code, message, .. } => {                if message.starts_with(&format!("{code}:")) {                    message.clone()                } else {                    format!("{code}: {message}")                }            }            ClientError::AcpOperation { message, .. } => message.clone(),            ClientError::PathNotAbsolute(_)            | ClientError::PathNotNormalized(_)            | ClientError::PathReadOnly(_)            | ClientError::ProcessNotFound(_)            | ClientError::ShellNotFound(_)            | ClientError::SessionNotFound(_)            | ClientError::InvalidSchedule(_)            | ClientError::PastSchedule(_)            | ClientError::Transport(_)            | ClientError::Sidecar(_) => self.to_string(),        }    }} /// Convenience alias for results carrying a typed [`ClientError`].pub type ClientResult<T> = std::result::Result<T, ClientError>; #[cfg(test)]mod tests {    use super::*;     #[test]    fn structured_resource_limit_metadata_survives_rejection_conversion() {        let error = ClientError::from_rejection(agentos_sidecar_client::wire::RejectedResponse {            code: String::from("ERR_AGENTOS_RESOURCE_LIMIT"),            message: String::from("handle command bytes exceeded"),            limit_name: Some(String::from("handleCommandBytes")),            configured_limit: Some(4096),            current_usage: Some(3072),            requested: Some(2048),            unit: Some(String::from("bytes")),            scope: Some(String::from("vm")),            vm_id: Some(String::from("vm-1")),            session_generation: Some(3),            capability_id: Some(11),            operation: Some(String::from("socket.write")),            configuration_path: Some(String::from("limits.reactor.maxHandleCommandBytes")),            retryable: Some(true),            errno: Some(String::from("EAGAIN")),        });         match error {            ClientError::ResourceLimit { details, .. } => {                assert_eq!(details.configured_limit, Some(4096));                assert_eq!(details.current_usage, Some(3072));                assert_eq!(details.requested, Some(2048));                assert_eq!(                    details.configuration_path.as_deref(),                    Some("limits.reactor.maxHandleCommandBytes")                );                assert_eq!(details.retryable, Some(true));                assert_eq!(details.errno.as_deref(), Some("EAGAIN"));            }            other => panic!("expected resource limit, got {other:?}"),        }    }     #[test]    fn acp_operation_keeps_code_separate_from_message() {        let error = ClientError::AcpOperation {            code: String::from("session_busy"),            message: String::from("session is running"),        };        match &error {            ClientError::AcpOperation { code, message } => {                assert_eq!(code, "session_busy");                assert_eq!(message, "session is running");            }            other => panic!("expected ACP operation error, got {other:?}"),        }        assert_eq!(error.batch_message(), "session is running");    }}