lifeloop-cli 0.3.0

Provider-neutral lifecycle abstraction and normalizer for AI harnesses
Documentation
//! Callback request/response and dispatch envelope wire shapes.

use serde::{Deserialize, Serialize};

use crate::{
    FailureClass, FrameContext, IntegrationMode, LifecycleEventKind, PayloadEnvelope, PayloadRef,
    ReceiptStatus, RetryClass, SCHEMA_VERSION, ValidationError, Warning, require_non_empty,
};

// ============================================================================
// Callback request and response envelopes
// ============================================================================

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CallbackRequest {
    pub schema_version: String,
    pub event: LifecycleEventKind,
    pub event_id: String,
    pub adapter_id: String,
    pub adapter_version: String,
    pub integration_mode: IntegrationMode,
    pub invocation_id: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub harness_session_id: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub harness_run_id: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub harness_task_id: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub frame_context: Option<FrameContext>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub capability_snapshot_ref: Option<String>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub payload_refs: Vec<PayloadRef>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub sequence: Option<u64>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub idempotency_key: Option<String>,
    #[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
    pub metadata: serde_json::Map<String, serde_json::Value>,
}

impl CallbackRequest {
    pub fn validate(&self) -> Result<(), ValidationError> {
        if self.schema_version != SCHEMA_VERSION {
            return Err(ValidationError::SchemaVersionMismatch {
                expected: SCHEMA_VERSION.to_string(),
                found: self.schema_version.clone(),
            });
        }
        require_non_empty(&self.event_id, "request.event_id")?;
        require_non_empty(&self.adapter_id, "request.adapter_id")?;
        require_non_empty(&self.adapter_version, "request.adapter_version")?;
        require_non_empty(&self.invocation_id, "request.invocation_id")?;
        if let Some(s) = &self.harness_session_id {
            require_non_empty(s, "request.harness_session_id")?;
        }
        if let Some(s) = &self.harness_run_id {
            require_non_empty(s, "request.harness_run_id")?;
        }
        if let Some(s) = &self.harness_task_id {
            require_non_empty(s, "request.harness_task_id")?;
        }
        if let Some(s) = &self.capability_snapshot_ref {
            require_non_empty(s, "request.capability_snapshot_ref")?;
        }
        if let Some(s) = &self.idempotency_key {
            require_non_empty(s, "request.idempotency_key")?;
        }
        if let Some(fc) = &self.frame_context {
            fc.validate()?;
        }
        for r in &self.payload_refs {
            r.validate()?;
        }
        match self.event {
            LifecycleEventKind::FrameOpening
            | LifecycleEventKind::FrameOpened
            | LifecycleEventKind::FrameEnding
            | LifecycleEventKind::FrameEnded
                if self.frame_context.is_none() =>
            {
                Err(ValidationError::InvalidRequest(
                    "frame.* events require frame_context".into(),
                ))
            }
            LifecycleEventKind::ReceiptEmitted if self.idempotency_key.is_some() => {
                Err(ValidationError::InvalidRequest(
                    "receipt.emitted is a notification event and must not carry an idempotency_key"
                        .into(),
                ))
            }
            _ => Ok(()),
        }
    }
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CallbackResponse {
    pub schema_version: String,
    pub status: ReceiptStatus,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub client_payloads: Vec<PayloadEnvelope>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub receipt_refs: Vec<String>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub warnings: Vec<Warning>,
    pub failure_class: Option<FailureClass>,
    pub retry_class: Option<RetryClass>,
    #[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
    pub metadata: serde_json::Map<String, serde_json::Value>,
}

impl CallbackResponse {
    pub fn ok(status: ReceiptStatus) -> Self {
        Self {
            schema_version: SCHEMA_VERSION.to_string(),
            status,
            client_payloads: Vec::new(),
            receipt_refs: Vec::new(),
            warnings: Vec::new(),
            failure_class: None,
            retry_class: None,
            metadata: serde_json::Map::new(),
        }
    }

    pub fn failed(failure: FailureClass) -> Self {
        Self {
            schema_version: SCHEMA_VERSION.to_string(),
            status: ReceiptStatus::Failed,
            client_payloads: Vec::new(),
            receipt_refs: Vec::new(),
            warnings: Vec::new(),
            failure_class: Some(failure),
            retry_class: Some(failure.default_retry()),
            metadata: serde_json::Map::new(),
        }
    }

    pub fn validate(&self) -> Result<(), ValidationError> {
        if self.schema_version != SCHEMA_VERSION {
            return Err(ValidationError::SchemaVersionMismatch {
                expected: SCHEMA_VERSION.to_string(),
                found: self.schema_version.clone(),
            });
        }
        for p in &self.client_payloads {
            p.validate()?;
        }
        for r in &self.receipt_refs {
            require_non_empty(r, "response.receipt_refs[]")?;
        }
        for w in &self.warnings {
            w.validate()?;
        }
        match (
            matches!(self.status, ReceiptStatus::Failed),
            self.failure_class.is_some(),
        ) {
            (true, false) => {
                return Err(ValidationError::InvalidResponse(
                    "status=failed requires failure_class".into(),
                ));
            }
            (false, true) => {
                return Err(ValidationError::InvalidResponse(
                    "failure_class is only valid on status=failed responses".into(),
                ));
            }
            _ => {}
        }
        if matches!(self.status, ReceiptStatus::Failed) && self.retry_class.is_none() {
            return Err(ValidationError::InvalidResponse(
                "status=failed requires retry_class (clients must declare retry posture)".into(),
            ));
        }
        Ok(())
    }
}

// ============================================================================
// Dispatch envelope (transport boundary)
// ============================================================================

/// Wire shape carrying a [`CallbackRequest`] and the opaque
/// [`PayloadEnvelope`] bodies a dispatch is delivering with.
///
/// The lifecycle contract distinguishes two concerns:
///
/// * the *request* a client receives describing what is happening
///   (event kind, frame context, [`CallbackRequest::payload_refs`]
///   pointing at named/sized/digested payloads), and
/// * the *envelope bodies* the request refers to.
///
/// Until issue #22 the CLI and the subprocess invoker only transported
/// the request — the envelopes were not delivered, so subprocess clients
/// could not reach payload bodies and negotiation never saw real
/// placement inputs. `DispatchEnvelope` is the transport-boundary shape
/// that carries both:
///
/// ```json
/// {
///   "schema_version": "lifeloop.v0.2",
///   "request": { "...CallbackRequest...": "..." },
///   "payloads": [ { "...PayloadEnvelope...": "..." } ]
/// }
/// ```
///
/// Lifeloop does not parse `payloads[].body` — it is transported
/// verbatim, consistent with the spec rule that bodies are opaque
/// (`docs/specs/lifecycle-contract/body.md`, "Opaque Payload Envelope").
/// `payloads` is omitted on the wire when empty.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DispatchEnvelope {
    pub schema_version: String,
    pub request: CallbackRequest,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub payloads: Vec<PayloadEnvelope>,
}

impl DispatchEnvelope {
    /// Construct a dispatch envelope at the canonical schema version.
    pub fn new(request: CallbackRequest, payloads: Vec<PayloadEnvelope>) -> Self {
        Self {
            schema_version: SCHEMA_VERSION.to_string(),
            request,
            payloads,
        }
    }

    /// Validate the envelope: schema version, the inner request, and
    /// each carried payload. Cross-correlation between
    /// `request.payload_refs` and `payloads[]` is intentionally *not*
    /// enforced here — a request may declare refs that are delivered
    /// out-of-band, and clients may receive bodies the request did not
    /// list (e.g. degraded fallback bodies). Cross-correlation belongs
    /// in negotiation/receipt synthesis, not the transport boundary.
    pub fn validate(&self) -> Result<(), ValidationError> {
        if self.schema_version != SCHEMA_VERSION {
            return Err(ValidationError::SchemaVersionMismatch {
                expected: SCHEMA_VERSION.to_string(),
                found: self.schema_version.clone(),
            });
        }
        self.request.validate()?;
        for p in &self.payloads {
            p.validate()?;
        }
        Ok(())
    }
}