lifeloop-cli 0.2.0

Provider-neutral lifecycle abstraction and normalizer for AI harnesses
Documentation
//! Typed pre-dispatch validation errors and adapter registry abstraction.
//!
//! Each variant of [`RouteError`] corresponds to one failure class named
//! in issue #7's acceptance criteria, so a downstream
//! [`super::FailureMapper`] can produce a deterministic
//! `failure_class` on the lifecycle receipt without re-inspecting the
//! request.

use crate::{AdapterManifest, RegisteredAdapter, lookup_manifest};

/// Reasons the router refused a [`crate::CallbackRequest`] before dispatch.
///
/// Variants are intentionally fine-grained: each acceptance-criterion
/// failure class gets its own variant so a future
/// [`super::FailureMapper`] can map them onto distinct
/// [`crate::FailureClass`] values without inspecting strings.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RouteError {
    /// `schema_version` on the inbound request did not match the
    /// router's compiled-in [`crate::SCHEMA_VERSION`].
    SchemaVersionMismatch { expected: String, found: String },

    /// A required identifier was the empty sentinel string. Carries
    /// the field path for diagnostics.
    EmptySentinel { field: &'static str },

    /// The deserialized request carried an event-kind value that is
    /// not part of the wire vocabulary.
    ///
    /// In practice serde rejects unknown enum wire names at
    /// deserialize time, but the variant exists so non-serde call
    /// sites (e.g. a future text-protocol bridge) can surface the
    /// same failure class without inventing a parallel error type.
    UnknownEventName { received: String },

    /// The deserialized request carried an enum value other than the
    /// event kind (e.g. `integration_mode`) whose wire name is not in
    /// the vocabulary. Preserved as a separate variant from
    /// [`Self::UnknownEventName`] so the failure class is unambiguous
    /// when mapping to a receipt.
    UnknownEnumName {
        field: &'static str,
        received: String,
    },

    /// `frame_context` violated a structural invariant: required for
    /// the event but absent, or partially populated (e.g.
    /// `parent_frame_id` without `frame_id`, or any frame field set
    /// without `frame_class`).
    InvalidFrameContext { detail: String },

    /// A payload reference failed structural validation (empty
    /// `payload_id` / `payload_kind`). Body semantics are opaque
    /// to the router — this only catches sentinel-empty fields on
    /// the reference itself.
    InvalidPayloadRef { index: usize, detail: String },

    /// The request's `event` is structurally illegal for this
    /// envelope (e.g. `receipt.emitted` carrying an
    /// `idempotency_key`). Distinct from frame-context errors so the
    /// failure class is unambiguous.
    InvalidEventEnvelope { detail: String },

    /// No registered adapter has an `adapter_id` matching the
    /// request. Distinct from [`Self::AdapterVersionMismatch`].
    AdapterIdNotFound { adapter_id: String },

    /// An adapter with the requested `adapter_id` is registered, but
    /// its `adapter_version` does not match the request. Carries
    /// both versions so a client can surface a precise upgrade
    /// suggestion.
    AdapterVersionMismatch {
        adapter_id: String,
        requested: String,
        registered: String,
    },
}

impl std::fmt::Display for RouteError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::SchemaVersionMismatch { expected, found } => write!(
                f,
                "schema_version mismatch: expected `{expected}`, found `{found}`"
            ),
            Self::EmptySentinel { field } => {
                write!(f, "empty sentinel string in field `{field}`")
            }
            Self::UnknownEventName { received } => {
                write!(f, "unknown lifecycle event wire name `{received}`")
            }
            Self::UnknownEnumName { field, received } => {
                write!(f, "unknown enum wire name `{received}` for field `{field}`")
            }
            Self::InvalidFrameContext { detail } => {
                write!(f, "invalid frame_context: {detail}")
            }
            Self::InvalidPayloadRef { index, detail } => {
                write!(f, "invalid payload_refs[{index}]: {detail}")
            }
            Self::InvalidEventEnvelope { detail } => {
                write!(f, "invalid event envelope: {detail}")
            }
            Self::AdapterIdNotFound { adapter_id } => {
                write!(f, "no registered adapter with id `{adapter_id}`")
            }
            Self::AdapterVersionMismatch {
                adapter_id,
                requested,
                registered,
            } => write!(
                f,
                "adapter `{adapter_id}` is registered at version `{registered}`, \
                 request asked for `{requested}`"
            ),
        }
    }
}

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

/// Resolution outcome for an `(adapter_id, adapter_version)` pair.
///
/// Distinguishes "no such adapter" from "wrong version of a known
/// adapter" so the router can return precise [`RouteError`]s
/// without the registry implementation having to know about
/// [`RouteError`] itself.
// `Found` carries a full `RegisteredAdapter` (~296 bytes) while the other
// variants are tiny. The size asymmetry is intentional: resolution is a
// short-lived stack value, the registry yields one at a time, and boxing
// would force every consumer (including the public `AdapterRegistry` trait
// impls) through an indirection that adds no value at this scale.
#[allow(clippy::large_enum_variant)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AdapterResolution {
    /// Adapter found and version matches.
    Found(RegisteredAdapter),
    /// Adapter id is unknown.
    UnknownId,
    /// Adapter id is known but the registered version is not the one
    /// the request asked for. Carries the registered version so the
    /// router can name both sides in the error.
    VersionMismatch { registered_version: String },
}

/// Source of [`AdapterManifest`]s the router consults at dispatch time.
///
/// Implemented for the built-in [`crate::manifest_registry`] via
/// [`BuiltinAdapterRegistry`]. Tests pass a fixture-style fake registry
/// that returns a synthetic manifest, which is how the router exercises
/// resolution against a fake adapter manifest without depending on any
/// specific lifecycle client.
pub trait AdapterRegistry {
    /// Resolve `(adapter_id, adapter_version)`. Implementations MUST
    /// distinguish "id unknown" from "id known, version mismatch" —
    /// the router needs both signals to produce a typed
    /// [`RouteError`].
    fn resolve(&self, adapter_id: &str, adapter_version: &str) -> AdapterResolution;
}

/// Adapter registry backed by [`crate::manifest_registry`].
///
/// Cheap to construct; holds no state. Each `resolve` call walks the
/// built-in registry. The registry is small (≈6 adapters) so a linear
/// scan is fine for the skeleton; a later issue may swap in a hash
/// index without changing this trait.
#[derive(Debug, Default, Clone, Copy)]
pub struct BuiltinAdapterRegistry;

impl AdapterRegistry for BuiltinAdapterRegistry {
    fn resolve(&self, adapter_id: &str, adapter_version: &str) -> AdapterResolution {
        match lookup_manifest(adapter_id) {
            None => AdapterResolution::UnknownId,
            Some(entry) => {
                if entry.manifest.adapter_version == adapter_version {
                    AdapterResolution::Found(entry)
                } else {
                    AdapterResolution::VersionMismatch {
                        registered_version: entry.manifest.adapter_version.clone(),
                    }
                }
            }
        }
    }
}

/// Borrow the manifest out of a resolution result. Used by the plan
/// stage; kept here so the public type hierarchy stays minimal.
pub(crate) fn manifest_of(resolution: &AdapterResolution) -> Option<&AdapterManifest> {
    match resolution {
        AdapterResolution::Found(entry) => Some(&entry.manifest),
        _ => None,
    }
}

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

    #[test]
    fn builtin_registry_resolves_codex_at_known_version() {
        let reg = BuiltinAdapterRegistry;
        let r = reg.resolve("codex", "0.1.0");
        assert!(matches!(r, AdapterResolution::Found(_)));
    }

    #[test]
    fn builtin_registry_distinguishes_unknown_id_from_version_mismatch() {
        let reg = BuiltinAdapterRegistry;
        assert_eq!(
            reg.resolve("nonexistent", "0.1.0"),
            AdapterResolution::UnknownId
        );
        match reg.resolve("codex", "9.9.9") {
            AdapterResolution::VersionMismatch { registered_version } => {
                assert_eq!(registered_version, "0.1.0");
            }
            other => panic!("expected VersionMismatch, got {other:?}"),
        }
    }
}