Skip to main content

lifeloop/router/
callbacks.rs

1//! Callback invocation (issue #14).
2//!
3//! Fills the [`CallbackInvoker`] seam declared in `src/router/seams.rs`
4//! (issue #7). This module owns the *Lifeloop side* of the client
5//! callback boundary: it turns a [`RoutingPlan`] plus the caller's
6//! delivered [`PayloadEnvelope`]s into an outbound [`CallbackRequest`]
7//! and hands it to a pluggable [`ClientCallback`] for delivery.
8//!
9//! # Boundary
10//!
11//! Owns:
12//! * the [`ClientCallback`] trait shape — the seam through which a
13//!   client receives a [`CallbackRequest`] and returns a
14//!   [`CallbackResponse`];
15//! * a function-pointer / closure adapter [`FnClientCallback`] that
16//!   lifts any `Fn(&CallbackRequest, &[PayloadEnvelope]) -> Result<CallbackResponse, E>`
17//!   into a [`ClientCallback`];
18//! * [`DefaultCallbackInvoker`], the concrete [`CallbackInvoker`]
19//!   impl that synthesizes the outbound [`CallbackRequest`] from a
20//!   [`RoutingPlan`] and dispatches it through the configured
21//!   [`ClientCallback`].
22//!
23//! Does **not** own:
24//! * real network or process-boundary transport (HTTP, gRPC, IPC,
25//!   stdio). Those are the *consumer's* concern: a consumer
26//!   constructs a [`ClientCallback`] backed by whatever transport
27//!   produces a typed [`CallbackResponse`]. This module only defines
28//!   how a [`RoutingPlan`] becomes a [`CallbackRequest`] and how the
29//!   response flows back to receipt synthesis;
30//! * payload body inspection. The `body`/`body_ref` fields on each
31//!   [`PayloadEnvelope`] pass through verbatim — this module never
32//!   parses them;
33//! * receipt emission. That is [`super::receipts`]'s job; the
34//!   invoker returns the raw [`CallbackResponse`] for the receipt
35//!   stage to consume.
36//!
37//! # Future-issue handoffs
38//!
39//! Issue #15 (failure-class mapping) consumes the
40//! `(CallbackRequest, CallbackResponse)` pair plus any
41//! [`super::FailureMapper`] errors a client returns.
42
43use crate::{CallbackRequest, CallbackResponse, PayloadEnvelope, SCHEMA_VERSION};
44
45use super::plan::RoutingPlan;
46use super::seams::CallbackInvoker;
47
48/// Pluggable client-callback seam.
49///
50/// A `ClientCallback` is whatever the consumer plugs in to actually
51/// deliver a [`CallbackRequest`] and produce a [`CallbackResponse`].
52/// In tests this is typically an inline closure; in production it
53/// might wrap an HTTP/IPC transport or a renderer-backed in-process
54/// dispatch (issue #3 will provide a renderer-backed implementation).
55///
56/// Implementations MUST treat payload bodies as opaque and MUST NOT
57/// mutate [`CallbackRequest`] in place.
58pub trait ClientCallback {
59    /// Error produced by the client transport. Kept generic so an
60    /// in-process impl, a renderer-backed impl, and a network impl
61    /// can each carry their own diagnostic shape.
62    type Error;
63
64    /// Deliver `request` (alongside any opaque `payloads`) to the
65    /// client and return its [`CallbackResponse`].
66    fn dispatch(
67        &self,
68        request: &CallbackRequest,
69        payloads: &[PayloadEnvelope],
70    ) -> Result<CallbackResponse, Self::Error>;
71}
72
73/// Adapter that lifts any `Fn(&CallbackRequest, &[PayloadEnvelope])
74/// -> Result<CallbackResponse, E>` into a [`ClientCallback`].
75///
76/// The fake-client tests stand up a [`ClientCallback`] inline through
77/// this type without IO.
78pub struct FnClientCallback<F, E>
79where
80    F: Fn(&CallbackRequest, &[PayloadEnvelope]) -> Result<CallbackResponse, E>,
81{
82    f: F,
83    _marker: std::marker::PhantomData<E>,
84}
85
86impl<F, E> FnClientCallback<F, E>
87where
88    F: Fn(&CallbackRequest, &[PayloadEnvelope]) -> Result<CallbackResponse, E>,
89{
90    pub fn new(f: F) -> Self {
91        Self {
92            f,
93            _marker: std::marker::PhantomData,
94        }
95    }
96}
97
98impl<F, E> ClientCallback for FnClientCallback<F, E>
99where
100    F: Fn(&CallbackRequest, &[PayloadEnvelope]) -> Result<CallbackResponse, E>,
101{
102    type Error = E;
103
104    fn dispatch(
105        &self,
106        request: &CallbackRequest,
107        payloads: &[PayloadEnvelope],
108    ) -> Result<CallbackResponse, Self::Error> {
109        (self.f)(request, payloads)
110    }
111}
112
113/// Concrete [`CallbackInvoker`] for issue #14.
114///
115/// Holds a [`ClientCallback`]. On [`CallbackInvoker::invoke`] it
116/// synthesizes a [`CallbackRequest`] from the [`RoutingPlan`] and
117/// delegates to the underlying client. The synthesized request
118/// preserves identifiers, integration mode, frame context, and
119/// payload references verbatim — payload bodies are not inspected.
120#[derive(Debug)]
121pub struct DefaultCallbackInvoker<C: ClientCallback> {
122    client: C,
123}
124
125impl<C: ClientCallback> DefaultCallbackInvoker<C> {
126    pub fn new(client: C) -> Self {
127        Self { client }
128    }
129
130    /// Borrow the underlying client (for tests/diagnostics).
131    pub fn client(&self) -> &C {
132        &self.client
133    }
134}
135
136/// Synthesize the outbound [`CallbackRequest`] from a [`RoutingPlan`].
137/// Free function (does not depend on the client type) so tests can inspect
138/// what the invoker would dispatch without naming a `ClientCallback`.
139pub fn synthesize_request(plan: &RoutingPlan) -> CallbackRequest {
140    CallbackRequest {
141        schema_version: SCHEMA_VERSION.to_string(),
142        event: plan.event,
143        event_id: plan.event_id.clone(),
144        adapter_id: plan.adapter.adapter_id.clone(),
145        adapter_version: plan.adapter.adapter_version.clone(),
146        integration_mode: plan.integration_mode,
147        invocation_id: plan.invocation_id.clone(),
148        harness_session_id: None,
149        harness_run_id: None,
150        harness_task_id: None,
151        frame_context: plan.frame_context.clone(),
152        capability_snapshot_ref: plan.capability_snapshot_ref.clone(),
153        payload_refs: plan.payload_refs.clone(),
154        sequence: plan.sequence,
155        idempotency_key: plan.idempotency_key.clone(),
156        metadata: serde_json::Map::new(),
157    }
158}
159
160impl<C: ClientCallback> CallbackInvoker for DefaultCallbackInvoker<C> {
161    type Error = C::Error;
162
163    fn invoke(
164        &self,
165        plan: &RoutingPlan,
166        payloads: &[PayloadEnvelope],
167    ) -> Result<CallbackResponse, Self::Error> {
168        let request = synthesize_request(plan);
169        self.client.dispatch(&request, payloads)
170    }
171}