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}