lifeloop-cli 0.3.0

Provider-neutral lifecycle abstraction and normalizer for AI harnesses
Documentation
//! Callback invocation (issue #14).
//!
//! Fills the [`CallbackInvoker`] seam declared in `src/router/seams.rs`
//! (issue #7). This module owns the *Lifeloop side* of the client
//! callback boundary: it turns a [`RoutingPlan`] plus the caller's
//! delivered [`PayloadEnvelope`]s into an outbound [`CallbackRequest`]
//! and hands it to a pluggable [`ClientCallback`] for delivery.
//!
//! # Boundary
//!
//! Owns:
//! * the [`ClientCallback`] trait shape — the seam through which a
//!   client receives a [`CallbackRequest`] and returns a
//!   [`CallbackResponse`];
//! * a function-pointer / closure adapter [`FnClientCallback`] that
//!   lifts any `Fn(&CallbackRequest, &[PayloadEnvelope]) -> Result<CallbackResponse, E>`
//!   into a [`ClientCallback`];
//! * [`DefaultCallbackInvoker`], the concrete [`CallbackInvoker`]
//!   impl that synthesizes the outbound [`CallbackRequest`] from a
//!   [`RoutingPlan`] and dispatches it through the configured
//!   [`ClientCallback`].
//!
//! Does **not** own:
//! * real network or process-boundary transport (HTTP, gRPC, IPC,
//!   stdio). Those are the *consumer's* concern: a consumer
//!   constructs a [`ClientCallback`] backed by whatever transport
//!   produces a typed [`CallbackResponse`]. This module only defines
//!   how a [`RoutingPlan`] becomes a [`CallbackRequest`] and how the
//!   response flows back to receipt synthesis;
//! * payload body inspection. The `body`/`body_ref` fields on each
//!   [`PayloadEnvelope`] pass through verbatim — this module never
//!   parses them;
//! * receipt emission. That is [`super::receipts`]'s job; the
//!   invoker returns the raw [`CallbackResponse`] for the receipt
//!   stage to consume.
//!
//! # Future-issue handoffs
//!
//! Issue #15 (failure-class mapping) consumes the
//! `(CallbackRequest, CallbackResponse)` pair plus any
//! [`super::FailureMapper`] errors a client returns.

use crate::{CallbackRequest, CallbackResponse, PayloadEnvelope, SCHEMA_VERSION};

use super::plan::RoutingPlan;
use super::seams::CallbackInvoker;

/// Pluggable client-callback seam.
///
/// A `ClientCallback` is whatever the consumer plugs in to actually
/// deliver a [`CallbackRequest`] and produce a [`CallbackResponse`].
/// In tests this is typically an inline closure; in production it
/// might wrap an HTTP/IPC transport or a renderer-backed in-process
/// dispatch (issue #3 will provide a renderer-backed implementation).
///
/// Implementations MUST treat payload bodies as opaque and MUST NOT
/// mutate [`CallbackRequest`] in place.
pub trait ClientCallback {
    /// Error produced by the client transport. Kept generic so an
    /// in-process impl, a renderer-backed impl, and a network impl
    /// can each carry their own diagnostic shape.
    type Error;

    /// Deliver `request` (alongside any opaque `payloads`) to the
    /// client and return its [`CallbackResponse`].
    fn dispatch(
        &self,
        request: &CallbackRequest,
        payloads: &[PayloadEnvelope],
    ) -> Result<CallbackResponse, Self::Error>;
}

/// Adapter that lifts any `Fn(&CallbackRequest, &[PayloadEnvelope])
/// -> Result<CallbackResponse, E>` into a [`ClientCallback`].
///
/// The fake-client tests stand up a [`ClientCallback`] inline through
/// this type without IO.
pub struct FnClientCallback<F, E>
where
    F: Fn(&CallbackRequest, &[PayloadEnvelope]) -> Result<CallbackResponse, E>,
{
    f: F,
    _marker: std::marker::PhantomData<E>,
}

impl<F, E> FnClientCallback<F, E>
where
    F: Fn(&CallbackRequest, &[PayloadEnvelope]) -> Result<CallbackResponse, E>,
{
    pub fn new(f: F) -> Self {
        Self {
            f,
            _marker: std::marker::PhantomData,
        }
    }
}

impl<F, E> ClientCallback for FnClientCallback<F, E>
where
    F: Fn(&CallbackRequest, &[PayloadEnvelope]) -> Result<CallbackResponse, E>,
{
    type Error = E;

    fn dispatch(
        &self,
        request: &CallbackRequest,
        payloads: &[PayloadEnvelope],
    ) -> Result<CallbackResponse, Self::Error> {
        (self.f)(request, payloads)
    }
}

/// Concrete [`CallbackInvoker`] for issue #14.
///
/// Holds a [`ClientCallback`]. On [`CallbackInvoker::invoke`] it
/// synthesizes a [`CallbackRequest`] from the [`RoutingPlan`] and
/// delegates to the underlying client. The synthesized request
/// preserves identifiers, integration mode, frame context, and
/// payload references verbatim — payload bodies are not inspected.
#[derive(Debug)]
pub struct DefaultCallbackInvoker<C: ClientCallback> {
    client: C,
}

impl<C: ClientCallback> DefaultCallbackInvoker<C> {
    pub fn new(client: C) -> Self {
        Self { client }
    }

    /// Borrow the underlying client (for tests/diagnostics).
    pub fn client(&self) -> &C {
        &self.client
    }
}

/// Synthesize the outbound [`CallbackRequest`] from a [`RoutingPlan`].
/// Free function (does not depend on the client type) so tests can inspect
/// what the invoker would dispatch without naming a `ClientCallback`.
pub fn synthesize_request(plan: &RoutingPlan) -> CallbackRequest {
    CallbackRequest {
        schema_version: SCHEMA_VERSION.to_string(),
        event: plan.event,
        event_id: plan.event_id.clone(),
        adapter_id: plan.adapter.adapter_id.clone(),
        adapter_version: plan.adapter.adapter_version.clone(),
        integration_mode: plan.integration_mode,
        invocation_id: plan.invocation_id.clone(),
        harness_session_id: None,
        harness_run_id: None,
        harness_task_id: None,
        frame_context: plan.frame_context.clone(),
        capability_snapshot_ref: plan.capability_snapshot_ref.clone(),
        payload_refs: plan.payload_refs.clone(),
        sequence: plan.sequence,
        idempotency_key: plan.idempotency_key.clone(),
        metadata: serde_json::Map::new(),
    }
}

impl<C: ClientCallback> CallbackInvoker for DefaultCallbackInvoker<C> {
    type Error = C::Error;

    fn invoke(
        &self,
        plan: &RoutingPlan,
        payloads: &[PayloadEnvelope],
    ) -> Result<CallbackResponse, Self::Error> {
        let request = synthesize_request(plan);
        self.client.dispatch(&request, payloads)
    }
}