Skip to main content

turul_a2a_aws_lambda/
lib.rs

1//! AWS Lambda adapter for [`turul-a2a`](https://docs.rs/turul-a2a): it feeds
2//! Lambda events to the same axum router the binary server uses, so one
3//! [`AgentExecutor`] runs unchanged
4//! behind a Function URL, API Gateway, ALB or SQS.
5//!
6//! Use it to deploy an A2A agent as a Lambda function; executor, storage and
7//! middleware come from `turul-a2a`.
8//!
9//! # Example
10//!
11//! ```toml
12//! [dependencies]
13//! turul-a2a = "0.2"
14//! turul-a2a-proto = "0.2"
15//! turul-a2a-aws-lambda = "0.2"
16//! async-trait = "0.1"
17//! lambda_runtime = "1"
18//! tokio = { version = "1", features = ["full"] }
19//! ```
20//!
21//! ```rust,no_run
22//! use turul_a2a::storage::InMemoryA2aStorage;
23//! use turul_a2a_aws_lambda::LambdaA2aServerBuilder;
24//! # use turul_a2a::prelude::*;
25//! # struct Echo;
26//! # #[async_trait::async_trait]
27//! # impl AgentExecutor for Echo {
28//! #     async fn execute(&self, _: &mut Task, _: &Message, _: &ExecutionContext)
29//! #         -> Result<(), A2aError> { Ok(()) }
30//! #     fn agent_card(&self) -> turul_a2a_proto::AgentCard { Default::default() }
31//! # }
32//!
33//! #[tokio::main]
34//! async fn main() -> Result<(), lambda_runtime::Error> {
35//!     // In-memory storage suits a demo; use a shared backend in production.
36//!     LambdaA2aServerBuilder::new()
37//!         .executor(Echo)
38//!         .storage(InMemoryA2aStorage::new())
39//!         .run()
40//!         .await
41//! }
42//! ```
43//!
44//! # Choosing a runner
45//!
46//! `LambdaA2aServerBuilder::run` is the default entry point. Without the
47//! `sqs` feature it is `run_http_only`; with it, `run_http_and_sqs`, which
48//! accepts either event shape. The named runners are strict and fail on any
49//! other shape: `run_http_only` (Function URL / API Gateway),
50//! `run_sqs_only` and `run_http_and_sqs` (both `sqs`). For a third trigger,
51//! match on [`classify_event`] yourself and pass HTTP events to
52//! [`LambdaA2aHandler::handle_http_event_value`].
53//! Behind a REST API Gateway stage, set
54//! [`LambdaA2aServerBuilder::strip_path_prefix`] so the router sees root-rooted paths.
55//!
56//! # Lambda behaviour
57//!
58//! - SSE responses are buffered: the task runs, its events are collected, and
59//!   one response is returned. `GET /tasks/{id}:subscribe` serves tasks that
60//!   are not yet terminal and closes on a terminal state or `INPUT_REQUIRED`.
61//! - Push-notification delivery cannot rely on work continuing after the
62//!   response. Recovery is carried by [`LambdaScheduledRecoveryHandler`]
63//!   (required on every backend) and, on DynamoDB, [`LambdaStreamRecoveryHandler`].
64//! - No cross-instance cancel poller runs. `CancelTask` writes its marker to the
65//!   shared backend, but a live executor on a different invocation does not
66//!   observe it.
67//!
68//! # Features
69//!
70//! | Feature | Enables |
71//! |---|---|
72//! | `sqs` | SQS-backed durable executor and the SQS runners |
73//! | `dynamodb` | `turul-a2a`'s DynamoDB backend |
74//!
75//! # Where next
76//!
77//! - [`LambdaAuthorizerMiddleware`] to map an API Gateway authorizer to a caller identity
78//! - [`lambda-agent`](https://github.com/aussierobots/turul-a2a/tree/main/examples/lambda-agent)
79//!   and the worker examples on GitHub
80
81mod adapter;
82mod auth;
83mod event;
84mod no_streaming;
85mod scheduled_recovery;
86mod stream_recovery;
87
88#[cfg(feature = "sqs")]
89mod durable;
90
91#[cfg(test)]
92mod builder_tests;
93#[cfg(test)]
94mod scheduled_recovery_tests;
95#[cfg(test)]
96mod stream_recovery_tests;
97
98pub use adapter::{axum_to_lambda_response, lambda_to_axum_request};
99pub use auth::{AuthorizerMapping, LambdaAuthorizerMiddleware};
100pub use event::{LambdaEvent, classify_event};
101pub use no_streaming::NoStreamingLayer;
102pub use scheduled_recovery::{
103    LambdaScheduledRecoveryConfig, LambdaScheduledRecoveryHandler, LambdaScheduledRecoveryResponse,
104    handle_maintenance_trigger,
105};
106pub use stream_recovery::LambdaStreamRecoveryHandler;
107
108#[cfg(feature = "sqs")]
109pub use durable::{SqsDurableExecutorQueue, drive_sqs_batch};
110
111use std::sync::Arc;
112
113use turul_a2a::error::A2aError;
114use turul_a2a::executor::AgentExecutor;
115use turul_a2a::middleware::{A2aMiddleware, MiddlewareStack};
116use turul_a2a::push::A2aPushDeliveryStore;
117use turul_a2a::router::{AppState, build_router};
118use turul_a2a::server::RuntimeConfig;
119use turul_a2a::storage::{
120    A2aAtomicStore, A2aCancellationSupervisor, A2aEventStore, A2aPushNotificationStorage,
121    A2aTaskStorage,
122};
123use turul_a2a::streaming::TaskEventBroker;
124
125/// Builder for Lambda A2A handler.
126///
127/// Use `.storage(my_storage)` to supply a single backend implementing all storage traits.
128/// All four storage traits must share the same backend so streaming, push delivery,
129/// and cross-instance cancellation coordinate correctly.
130///
131/// For push-notification delivery, wire `.storage()` with a backend that implements
132/// [`A2aPushDeliveryStore`] (all four first-party backends do). The atomic store
133/// must also opt in via `with_push_dispatch_enabled(true)`; the
134/// builder rejects configurations where the flag and the delivery store disagree.
135pub struct LambdaA2aServerBuilder {
136    executor: Option<Arc<dyn AgentExecutor>>,
137    task_storage: Option<Arc<dyn A2aTaskStorage>>,
138    push_storage: Option<Arc<dyn A2aPushNotificationStorage>>,
139    event_store: Option<Arc<dyn A2aEventStore>>,
140    atomic_store: Option<Arc<dyn A2aAtomicStore>>,
141    cancellation_supervisor: Option<Arc<dyn A2aCancellationSupervisor>>,
142    push_delivery_store: Option<Arc<dyn A2aPushDeliveryStore>>,
143    middleware: Vec<Arc<dyn A2aMiddleware>>,
144    runtime_config: RuntimeConfig,
145    durable_executor_queue: Option<Arc<dyn turul_a2a::durable_executor::DurableExecutorQueue>>,
146    path_prefix: Option<String>,
147    outbound_url_validator: Option<turul_a2a::push::ssrf::OutboundUrlValidator>,
148}
149
150impl LambdaA2aServerBuilder {
151    pub fn new() -> Self {
152        Self {
153            executor: None,
154            task_storage: None,
155            push_storage: None,
156            event_store: None,
157            atomic_store: None,
158            cancellation_supervisor: None,
159            push_delivery_store: None,
160            middleware: vec![],
161            runtime_config: RuntimeConfig::default(),
162            durable_executor_queue: None,
163            path_prefix: None,
164            outbound_url_validator: None,
165        }
166    }
167
168    /// Configure an HTTP path prefix to strip before the A2A router
169    /// sees the request. Needed when Lambda sits behind an API Gateway
170    /// whose stage + resource tree is forwarded into the function
171    /// (e.g. REST API `AWS_PROXY` integrations surface
172    /// `/stage/agent/message:send` where the A2A
173    /// router expects `/message:send`).
174    ///
175    /// The prefix must start with `/` and must not end with `/`
176    /// (unless it is exactly `/`, which is a no-op). Segment boundaries
177    /// are respected — a prefix of `/dev` will not strip `/devs/...`.
178    /// Paths that do not start with the prefix pass through unchanged
179    /// (the router will 404 on a genuinely unknown path, which is the
180    /// correct failure mode). Applies to both
181    /// [`LambdaA2aHandler::run_http_only`] and
182    /// `LambdaA2aHandler::run_http_and_sqs`. SQS events are
183    /// unaffected.
184    pub fn strip_path_prefix(mut self, prefix: impl Into<String>) -> Self {
185        self.path_prefix = Some(prefix.into());
186        self
187    }
188
189    /// Wire a durable executor queue. When set, the
190    /// `return_immediately = true` path in `core_send_message` enqueues
191    /// a [`turul_a2a::durable_executor::QueuedExecutorJob`] on this
192    /// queue instead of spawning the executor locally, and the
193    /// capability flag `RuntimeConfig::supports_return_immediately` is
194    /// turned **back on** as an implementation detail — the capability
195    /// cannot be claimed without supplying the queue.
196    ///
197    /// Adopters should typically reach for the SQS-specific helper
198    /// `Self::with_sqs_return_immediately` instead; this generic
199    /// method exists so adopters can inject in-memory fakes for tests
200    /// or provide non-SQS transports (Kinesis, Step Functions task
201    /// token, self-invoke, etc.).
202    pub fn with_durable_executor(
203        mut self,
204        queue: Arc<dyn turul_a2a::durable_executor::DurableExecutorQueue>,
205    ) -> Self {
206        self.durable_executor_queue = Some(queue);
207        self.runtime_config.supports_return_immediately = true;
208        self
209    }
210
211    /// Wire the AWS SQS durable executor queue. Ergonomic
212    /// helper over [`Self::with_durable_executor`] that constructs a
213    /// [`SqsDurableExecutorQueue`] from a `queue_url` + shared SQS
214    /// client. Requires the `sqs` feature.
215    #[cfg(feature = "sqs")]
216    pub fn with_sqs_return_immediately(
217        self,
218        queue_url: impl Into<String>,
219        sqs_client: Arc<aws_sdk_sqs::Client>,
220    ) -> Self {
221        let queue = Arc::new(SqsDurableExecutorQueue::new(queue_url, sqs_client));
222        self.with_durable_executor(queue)
223    }
224
225    pub fn executor(mut self, exec: impl AgentExecutor + 'static) -> Self {
226        self.executor = Some(Arc::new(exec));
227        self
228    }
229
230    /// Set all storage from a single backend instance.
231    ///
232    /// This is the **preferred** method — a single struct implementing all
233    /// storage traits guarantees the same-backend requirement
234    /// AND ensures the cancellation supervisor reads the same backend
235    /// the cancel marker is written to. A mismatch here (e.g., DynamoDB
236    /// task storage + in-memory cancellation supervisor) silently
237    /// breaks cross-instance cancellation.
238    ///
239    /// errata: `.storage()` wires storage traits only. It does
240    /// **not** auto-register the storage as a push-delivery store, even if
241    /// the backend happens to implement [`A2aPushDeliveryStore`]. To opt in
242    /// to push delivery, call [`Self::push_delivery_store`] explicitly and
243    /// call `with_push_dispatch_enabled(true)` on the storage instance
244    /// before passing it here. Non-push deployments need neither.
245    pub fn storage<S>(mut self, storage: S) -> Self
246    where
247        S: A2aTaskStorage
248            + A2aPushNotificationStorage
249            + A2aEventStore
250            + A2aAtomicStore
251            + A2aCancellationSupervisor
252            + Clone
253            + 'static,
254    {
255        self.task_storage = Some(Arc::new(storage.clone()));
256        self.push_storage = Some(Arc::new(storage.clone()));
257        self.event_store = Some(Arc::new(storage.clone()));
258        self.atomic_store = Some(Arc::new(storage.clone()));
259        self.cancellation_supervisor = Some(Arc::new(storage));
260        self
261    }
262
263    /// Set task storage individually. Must be paired with event_store/atomic_store
264    /// AND `cancellation_supervisor()` for same-backend compliance.
265    pub fn task_storage(mut self, s: impl A2aTaskStorage + 'static) -> Self {
266        self.task_storage = Some(Arc::new(s));
267        self
268    }
269
270    /// Set push notification storage individually.
271    pub fn push_storage(mut self, s: impl A2aPushNotificationStorage + 'static) -> Self {
272        self.push_storage = Some(Arc::new(s));
273        self
274    }
275
276    /// Set event store individually. Must match task_storage backend.
277    pub fn event_store(mut self, s: impl A2aEventStore + 'static) -> Self {
278        self.event_store = Some(Arc::new(s));
279        self
280    }
281
282    /// Set atomic store individually. Must match task_storage backend.
283    pub fn atomic_store(mut self, s: impl A2aAtomicStore + 'static) -> Self {
284        self.atomic_store = Some(Arc::new(s));
285        self
286    }
287
288    /// Set the cancellation supervisor individually. Must match
289    /// task_storage backend — otherwise `:cancel` writes the marker to
290    /// one backend and the supervisor reads from another, silently
291    /// breaking cross-instance cancellation. Prefer `.storage()` for
292    /// unified wiring. Consumed by `core_cancel_task`.
293    pub fn cancellation_supervisor(mut self, s: impl A2aCancellationSupervisor + 'static) -> Self {
294        self.cancellation_supervisor = Some(Arc::new(s));
295        self
296    }
297
298    /// Set the push delivery store individually.
299    ///
300    /// Prefer `.storage()` for unified wiring. The delivery store MUST
301    /// be on the same backend as `.task_storage(...)` — the builder
302    /// rejects mismatches. Passing a delivery store also requires the
303    /// atomic store to have opted in via `with_push_dispatch_enabled(true)`
304    ///.
305    pub fn push_delivery_store(mut self, store: impl A2aPushDeliveryStore + 'static) -> Self {
306        self.push_delivery_store = Some(Arc::new(store));
307        self
308    }
309
310    /// Override the runtime configuration (timeouts, retry budgets,
311    /// push tuning). Defaults to [`RuntimeConfig::default()`]. The
312    /// builder validates push settings (claim expiry vs retry horizon,
313    /// ) when `.push_delivery_store(...)` is wired.
314    pub fn runtime_config(mut self, cfg: RuntimeConfig) -> Self {
315        self.runtime_config = cfg;
316        self
317    }
318
319    pub fn middleware(mut self, mw: Arc<dyn A2aMiddleware>) -> Self {
320        self.middleware.push(mw);
321        self
322    }
323
324    /// Operator allowlist for push webhook URLs.
325    ///
326    /// Called with each webhook URL on every delivery attempt, before
327    /// DNS resolution. Returning `Err` refuses the delivery: nothing is
328    /// resolved or POSTed, and the delivery is recorded as a failed
329    /// delivery with error class `SSRFBlocked`. The reason string is
330    /// not persisted. Runs in addition to the built-in scheme and
331    /// private-address checks, not instead of them. Takes effect only
332    /// when push delivery is wired (`push_delivery_store`).
333    pub fn outbound_url_validator(
334        mut self,
335        validator: turul_a2a::push::ssrf::OutboundUrlValidator,
336    ) -> Self {
337        self.outbound_url_validator = Some(validator);
338        self
339    }
340
341    pub fn build(self) -> Result<LambdaA2aHandler, A2aError> {
342        let executor = self
343            .executor
344            .ok_or(A2aError::Internal("executor is required".into()))?;
345        let task_storage = self.task_storage.ok_or(A2aError::Internal(
346            "task_storage is required for Lambda".into(),
347        ))?;
348        let push_storage = self.push_storage.ok_or(A2aError::Internal(
349            "push_storage is required for Lambda".into(),
350        ))?;
351        let event_store: Arc<dyn A2aEventStore> = self.event_store.ok_or(A2aError::Internal(
352            "event_store is required for Lambda (use .storage() for unified backend)".into(),
353        ))?;
354        let atomic_store: Arc<dyn A2aAtomicStore> = self.atomic_store.ok_or(A2aError::Internal(
355            "atomic_store is required for Lambda (use .storage() for unified backend)".into(),
356        ))?;
357        // the cancellation supervisor MUST be supplied. No
358        // InMemoryA2aStorage fallback: that would silently break
359        // cross-instance cancellation by reading markers from a
360        // different backend than they were written to.
361        let cancellation_supervisor: Arc<dyn A2aCancellationSupervisor> =
362            self.cancellation_supervisor.ok_or(A2aError::Internal(
363                "cancellation_supervisor is required for Lambda. Use .storage() for unified \
364                 backend wiring, or .cancellation_supervisor(...) alongside the individual \
365                 storage setters. Omitting it silently breaks cross-instance cancellation."
366                    .into(),
367            ))?;
368
369        // Same-backend enforcement: all storage traits + the
370        // cancellation supervisor must report the same `backend_name`.
371        let task_backend = task_storage.backend_name();
372        let push_backend = push_storage.backend_name();
373        let event_backend = event_store.backend_name();
374        let atomic_backend = atomic_store.backend_name();
375        let supervisor_backend = cancellation_supervisor.backend_name();
376        if task_backend != push_backend
377            || task_backend != event_backend
378            || task_backend != atomic_backend
379            || task_backend != supervisor_backend
380        {
381            return Err(A2aError::Internal(format!(
382                "Storage backend mismatch: task={task_backend}, push={push_backend}, \
383                 event={event_backend}, atomic={atomic_backend}, \
384                 cancellation_supervisor={supervisor_backend}. \
385                 All storage traits, the cancellation supervisor included, \
386                 must share one backend so cross-instance cancel markers \
387                 are observable. Use .storage() for unified backend."
388            )));
389        }
390
391        let push_delivery_store = self.push_delivery_store;
392
393        // Push-dispatch consistency: the atomic store's
394        // opt-in flag and the presence of `push_delivery_store` MUST
395        // agree. Both-true = push fully wired; both-false = non-push
396        // deployment. Mixed cases are build errors — this mirrors the
397        // main server builder (server/mod.rs).
398        match (
399            push_delivery_store.is_some(),
400            atomic_store.push_dispatch_enabled(),
401        ) {
402            (true, true) | (false, false) => {}
403            (true, false) => {
404                return Err(A2aError::Internal(
405                    "push_delivery_store wired but atomic_store.push_dispatch_enabled() \
406                     is false. Call .with_push_dispatch_enabled(true) on the backend \
407                     storage before passing it to .storage()."
408                        .into(),
409                ));
410            }
411            (false, true) => {
412                return Err(A2aError::Internal(
413                    "atomic_store.push_dispatch_enabled() is true but no \
414                     push_delivery_store is wired. Pending-dispatch markers would be \
415                     written with no consumer, imposing load-bearing infra for no \
416                     benefit. If you need to populate markers for an external \
417                     consumer, open an issue for a distinctly-named opt-in — for now, \
418                     this configuration is rejected."
419                        .into(),
420                ));
421            }
422        }
423
424        // The Lambda execution environment can freeze once the HTTP
425        // response is flushed, so a `tokio::spawn`'d executor
426        // continuation is opportunistic only. Refuse
427        // `SendMessageConfiguration.return_immediately = true` at the
428        // `core_send_message` entry point (UnsupportedOperationError —
429        // an aspect of SendMessage this runtime cannot honour, A2A
430        // v1.0.1 §3.3.2) via this capability flag instead of silently
431        // orphaning the executor.
432        //
433        // if a durable executor queue is wired via
434        // [`Self::with_durable_executor`] (or the SQS helper), the
435        // capability is re-enabled because enqueueing is durable
436        // across the Lambda freeze. The explicit re-enable lives in
437        // the builder method so the capability cannot be claimed
438        // without the mechanism.
439        let mut runtime_config = self.runtime_config;
440        if self.durable_executor_queue.is_none() {
441            runtime_config.supports_return_immediately = false;
442        }
443        // No background loops run on Lambda, so a lease written here
444        // would never be renewed and peers would fail the live task.
445        runtime_config.executor_lease_ttl = None;
446        runtime_config.executor_stall_timeout = None;
447        // The idle-task sweep needs no loop: the scheduled-recovery
448        // handler runs it.
449        if runtime_config
450            .idle_task_timeout
451            .is_some_and(|d| d.is_zero())
452        {
453            return Err(A2aError::Internal(
454                "idle_task_timeout must be greater than zero".into(),
455            ));
456        }
457
458        // Push dispatcher wiring. When
459        // `push_delivery_store` is present, validate its backend matches,
460        // validate the retry-horizon invariant, and construct the
461        // `PushDispatcher`. Under Lambda the dispatcher runs
462        // opportunistically post-return; correctness
463        // comes from the atomic marker + stream/scheduler recovery.
464        let push_dispatcher: Option<Arc<turul_a2a::push::PushDispatcher>> = if let Some(delivery) =
465            push_delivery_store.as_ref()
466        {
467            let push_delivery_backend = delivery.backend_name();
468            if task_backend != push_delivery_backend {
469                return Err(A2aError::Internal(format!(
470                    "Storage backend mismatch: task={task_backend}, \
471                         push_delivery={push_delivery_backend}. \
472                         All storage traits must share the same backend."
473                )));
474            }
475
476            // claim expiry must exceed the worst-case
477            // retry horizon so a live claim is not mis-classified as
478            // stale mid-retry.
479            let retry_horizon = runtime_config
480                .push_backoff_cap
481                .saturating_mul(runtime_config.push_max_attempts as u32);
482            if runtime_config.push_claim_expiry <= retry_horizon {
483                return Err(A2aError::Internal(format!(
484                    "push_claim_expiry ({:?}) must be greater than retry horizon \
485                         (push_max_attempts={} * push_backoff_cap={:?} = {:?}). \
486                         Raise push_claim_expiry or lower push_max_attempts/push_backoff_cap.",
487                    runtime_config.push_claim_expiry,
488                    runtime_config.push_max_attempts,
489                    runtime_config.push_backoff_cap,
490                    retry_horizon
491                )));
492            }
493
494            let delivery_cfg =
495                turul_a2a::push::delivery::PushDeliveryConfig::from_runtime_config(&runtime_config);
496
497            let instance_id = format!("a2a-lambda-{}", uuid::Uuid::now_v7());
498            let worker = turul_a2a::push::delivery::PushDeliveryWorker::new(
499                delivery.clone(),
500                delivery_cfg,
501                self.outbound_url_validator.clone(),
502                instance_id,
503            )
504            .map_err(|e| A2aError::Internal(format!("push worker build failed: {e}")))?;
505
506            Some(Arc::new(turul_a2a::push::PushDispatcher::new(
507                Arc::new(worker),
508                push_storage.clone(),
509                task_storage.clone(),
510            )))
511        } else {
512            None
513        };
514
515        let state = AppState {
516            executor,
517            task_storage,
518            push_storage,
519            event_store,
520            atomic_store,
521            event_broker: TaskEventBroker::new(),
522            middleware_stack: Arc::new(MiddlewareStack::new(self.middleware)),
523            runtime_config,
524            in_flight: Arc::new(turul_a2a::server::in_flight::InFlightRegistry::new()),
525            cancellation_supervisor,
526            push_delivery_store,
527            push_dispatcher,
528            durable_executor_queue: self.durable_executor_queue,
529        };
530
531        let router = build_router(state.clone());
532
533        let path_prefix = match self.path_prefix {
534            None => None,
535            Some(p) if p.is_empty() || p == "/" => None,
536            Some(p) => {
537                if !p.starts_with('/') {
538                    return Err(A2aError::InvalidRequest {
539                        message: format!(
540                            "LambdaA2aServerBuilder::strip_path_prefix: prefix must start with '/'; got {p:?}"
541                        ),
542                    });
543                }
544                if p.ends_with('/') {
545                    return Err(A2aError::InvalidRequest {
546                        message: format!(
547                            "LambdaA2aServerBuilder::strip_path_prefix: prefix must not end with '/' (except '/'); got {p:?}"
548                        ),
549                    });
550                }
551                Some(Arc::from(p))
552            }
553        };
554
555        Ok(LambdaA2aHandler {
556            router,
557            state,
558            path_prefix,
559        })
560    }
561}
562
563impl Default for LambdaA2aServerBuilder {
564    fn default() -> Self {
565        Self::new()
566    }
567}
568
569/// Lambda handler wrapping the axum Router.
570#[derive(Clone)]
571pub struct LambdaA2aHandler {
572    router: axum::Router,
573    /// Held for SQS-event dispatch (`handle_sqs`) and the idle-task
574    /// sweep (`cancel_idle_tasks`).
575    state: AppState,
576    /// Leading path segment to strip from each incoming HTTP request
577    /// before the A2A router sees it. `None` = no stripping. Set via
578    /// [`LambdaA2aServerBuilder::strip_path_prefix`].
579    path_prefix: Option<Arc<str>>,
580}
581
582impl LambdaA2aHandler {
583    pub fn builder() -> LambdaA2aServerBuilder {
584        LambdaA2aServerBuilder::new()
585    }
586
587    /// Cancel tasks left waiting on their client for longer than the
588    /// runtime configuration's `idle_task_timeout`; see
589    /// [`turul_a2a::server::A2aServerBuilder::idle_task_timeout`]. With no
590    /// timeout set this does nothing. Run by
591    /// [`LambdaScheduledRecoveryHandler::with_idle_task_sweep`].
592    pub async fn cancel_idle_tasks(
593        &self,
594    ) -> Result<turul_a2a::server::IdleSweep, turul_a2a::storage::A2aStorageError> {
595        match self.state.runtime_config.idle_task_timeout {
596            Some(idle_for) => {
597                turul_a2a::server::liveness::cancel_idle_tasks(
598                    &self.state,
599                    Some(&self.state.in_flight),
600                    idle_for,
601                )
602                .await
603            }
604            None => Ok(Default::default()),
605        }
606    }
607
608    /// Handle a Lambda HTTP request.
609    pub async fn handle(
610        &self,
611        event: lambda_http::Request,
612    ) -> Result<lambda_http::Response<lambda_http::Body>, lambda_http::Error> {
613        let axum_req = lambda_to_axum_request(event)?;
614        let axum_req = match &self.path_prefix {
615            Some(prefix) => adapter::strip_request_path_prefix(axum_req, prefix),
616            None => axum_req,
617        };
618
619        let axum_resp = tower::ServiceExt::oneshot(self.router.clone(), axum_req)
620            .await
621            .map_err(|e| lambda_http::Error::from(format!("Router error: {e}")))?;
622
623        axum_to_lambda_response(axum_resp).await
624    }
625
626    /// Handle a raw Lambda HTTP event delivered as `serde_json::Value`
627    /// and return a response envelope **matching the inbound event
628    /// shape**:
629    ///
630    /// | Inbound shape detected by `lambda_http::LambdaRequest`   | Response envelope                  |
631    /// |----------------------------------------------------------|------------------------------------|
632    /// | API Gateway v1 (REST) — top-level `httpMethod` + `path`  | `ApiGatewayProxyResponse`          |
633    /// | API Gateway v2 / Function URL — `requestContext.http`    | `ApiGatewayV2httpResponse`         |
634    /// | ALB target group — `requestContext.elb`                  | `AlbTargetGroupResponse`           |
635    /// | WebSocket / unrecognised                                  | `Err(...)`                         |
636    ///
637    /// Mismatching envelopes — most commonly a v2 response sent in
638    /// response to a v1 invocation — cause API Gateway to return
639    /// `502 Bad Gateway` regardless of the body, because the response
640    /// fails the gateway's contract validation. This method ensures
641    /// symmetry: response shape is always picked from the inbound
642    /// shape, never hardcoded.
643    ///
644    /// This is the framework's canonical HTTP envelope conversion
645    /// primitive — useful when a single Lambda function routes among
646    /// HTTP and one or more non-framework triggers (EventBridge,
647    /// DynamoDB streams, custom invoke) and the adopter drives
648    /// [`lambda_runtime::run`] with `serde_json::Value` directly.
649    /// Handles the `LambdaRequest` deserialization, the text-vs-base64
650    /// content-type detection, and the response envelope rebuild.
651    ///
652    /// Body encoding: text for `text/*`, `application/json`,
653    /// `application/xml`, `application/javascript`, any `+json` / `+xml`
654    /// structured-syntax suffix (RFC 6839, e.g. `application/a2a+json`),
655    /// or anything with `charset=`; base64 otherwise.
656    ///
657    /// Path-prefix stripping (see
658    /// [`LambdaA2aServerBuilder::strip_path_prefix`]) is applied
659    /// because dispatch flows through [`Self::handle`]. Adopters who
660    /// only serve HTTP should continue to use [`Self::run_http_only`]
661    /// or [`LambdaA2aServerBuilder::run`]; this entry point exists for
662    /// composition with adopter-owned third triggers.
663    pub async fn handle_http_event_value(
664        &self,
665        value: serde_json::Value,
666    ) -> Result<serde_json::Value, lambda_runtime::Error> {
667        use base64::Engine;
668        use http_body_util::BodyExt;
669
670        let lambda_req: lambda_http::request::LambdaRequest = serde_json::from_value(value)
671            .map_err(|e| lambda_runtime::Error::from(format!("invalid HTTP event: {e}")))?;
672
673        // Capture the inbound shape so we can emit the matching
674        // response envelope. Lost once we convert to `lambda_http::Request`.
675        let shape = match &lambda_req {
676            lambda_http::request::LambdaRequest::ApiGatewayV1(_) => HttpEventShape::ApiGatewayV1,
677            lambda_http::request::LambdaRequest::ApiGatewayV2(_) => HttpEventShape::ApiGatewayV2,
678            lambda_http::request::LambdaRequest::Alb(_) => HttpEventShape::Alb,
679            lambda_http::request::LambdaRequest::WebSocket(_) => {
680                return Err(lambda_runtime::Error::from(
681                    "WebSocket Lambda events are not supported by this adapter",
682                ));
683            }
684            _ => {
685                return Err(lambda_runtime::Error::from(
686                    "unsupported Lambda HTTP event variant",
687                ));
688            }
689        };
690
691        let req: lambda_http::Request = lambda_req.into();
692
693        let resp = self
694            .handle(req)
695            .await
696            .map_err(|e| lambda_runtime::Error::from(format!("handler error: {e}")))?;
697        let (parts, body) = resp.into_parts();
698
699        let bytes = body
700            .collect()
701            .await
702            .map_err(|e| lambda_runtime::Error::from(format!("body collect: {e}")))?
703            .to_bytes();
704
705        let ct = parts
706            .headers
707            .get(http::header::CONTENT_TYPE)
708            .and_then(|v| v.to_str().ok())
709            .unwrap_or("application/octet-stream")
710            .to_ascii_lowercase();
711        let essence = ct.split(';').next().unwrap_or("").trim();
712        let is_text = essence.starts_with("text/")
713            || matches!(
714                essence,
715                "application/json" | "application/xml" | "application/javascript"
716            )
717            || essence.ends_with("+json")
718            || essence.ends_with("+xml")
719            || ct.contains("charset=");
720
721        let (body_str, is_base64) = if bytes.is_empty() {
722            (None, false)
723        } else if is_text {
724            match std::str::from_utf8(&bytes) {
725                Ok(s) => (Some(s.to_string()), false),
726                Err(_) => (
727                    Some(base64::engine::general_purpose::STANDARD.encode(&bytes)),
728                    true,
729                ),
730            }
731        } else {
732            (
733                Some(base64::engine::general_purpose::STANDARD.encode(&bytes)),
734                true,
735            )
736        };
737
738        let status = parts.status.as_u16() as i64;
739        let headers = parts.headers;
740        let body_lambda = body_str.map(aws_lambda_events::encodings::Body::Text);
741
742        match shape {
743            HttpEventShape::ApiGatewayV1 => {
744                let mut api_resp = aws_lambda_events::apigw::ApiGatewayProxyResponse::default();
745                api_resp.status_code = status;
746                api_resp.headers = headers;
747                api_resp.body = body_lambda;
748                api_resp.is_base64_encoded = is_base64;
749                serde_json::to_value(api_resp).map_err(|e| {
750                    lambda_runtime::Error::from(format!("serialise APIGW v1 response: {e}"))
751                })
752            }
753            HttpEventShape::ApiGatewayV2 => {
754                let mut api_resp = aws_lambda_events::apigw::ApiGatewayV2httpResponse::default();
755                api_resp.status_code = status;
756                api_resp.headers = headers;
757                api_resp.body = body_lambda;
758                api_resp.is_base64_encoded = is_base64;
759                serde_json::to_value(api_resp).map_err(|e| {
760                    lambda_runtime::Error::from(format!("serialise APIGW v2 response: {e}"))
761                })
762            }
763            HttpEventShape::Alb => {
764                let mut api_resp = aws_lambda_events::alb::AlbTargetGroupResponse::default();
765                api_resp.status_code = status;
766                api_resp.headers = headers;
767                api_resp.body = body_lambda;
768                api_resp.is_base64_encoded = is_base64;
769                serde_json::to_value(api_resp).map_err(|e| {
770                    lambda_runtime::Error::from(format!("serialise ALB response: {e}"))
771                })
772            }
773        }
774    }
775}
776
777/// Inbound HTTP event shape detected by [`LambdaA2aHandler::handle_http_event_value`].
778///
779/// The response envelope must match the inbound shape — see method
780/// docs for the contract.
781#[derive(Debug, Clone, Copy, PartialEq, Eq)]
782enum HttpEventShape {
783    ApiGatewayV1,
784    ApiGatewayV2,
785    Alb,
786}
787
788impl LambdaA2aHandler {
789    /// Handle an SQS batch from the durable executor queue.
790    ///
791    /// Returns `SqsBatchResponse` whose `batch_item_failures` list
792    /// tells the event source mapping which records to retry.
793    /// Requires `FunctionResponseTypes: ["ReportBatchItemFailures"]`
794    /// on the mapping.
795    ///
796    /// Each record: deserialize envelope → load task → terminal-no-op
797    /// check → cancel-marker direct-CANCELED commit → run executor.
798    /// See `durable::drive_sqs_batch` for the full semantics.
799    #[cfg(feature = "sqs")]
800    pub async fn handle_sqs(
801        &self,
802        event: aws_lambda_events::event::sqs::SqsEvent,
803    ) -> aws_lambda_events::event::sqs::SqsBatchResponse {
804        durable::drive_sqs_batch(&self.state, event).await
805    }
806
807    /// Run this handler as a pure HTTP Lambda. Strict: any non-HTTP
808    /// event shape (SQS, DynamoDB stream, scheduler, …) causes
809    /// `lambda_http::run` to return a deserialization error on
810    /// invocation.
811    ///
812    /// Appropriate for Lambdas whose only trigger is a Function URL,
813    /// API Gateway, or ALB. The request Lambda in the two-Lambda
814    /// durable-executor topology is the canonical caller.
815    pub async fn run_http_only(self) -> Result<(), lambda_runtime::Error> {
816        let handler = Arc::new(self);
817        lambda_http::run(lambda_http::service_fn(move |event| {
818            let h = Arc::clone(&handler);
819            async move { h.handle(event).await }
820        }))
821        .await
822    }
823}
824
825// Default entrypoint when the `sqs` feature is off — `.run()` aliases
826// the HTTP-only runner. With the `sqs` feature enabled, `.run()` lives
827// in `durable.rs` and routes HTTP+SQS via the classifier.
828#[cfg(not(feature = "sqs"))]
829impl LambdaA2aHandler {
830    /// Default Lambda runner. Dispatches based on the event shape the
831    /// binary receives. Without the `sqs` feature this is HTTP-only
832    /// (equivalent to [`LambdaA2aHandler::run_http_only`]).
833    pub async fn run(self) -> Result<(), lambda_runtime::Error> {
834        self.run_http_only().await
835    }
836}
837
838impl LambdaA2aServerBuilder {
839    /// Build and run in one call. Sugar for
840    /// `self.build()?.run().await`. Hides the handler from the adopter —
841    /// use `.build()` + `handler.run_*()` instead when the handler is
842    /// needed for unit tests, custom middleware, or an explicit
843    /// topology runner.
844    pub async fn run(self) -> Result<(), lambda_runtime::Error> {
845        let handler = self.build().map_err(|e| {
846            lambda_runtime::Error::from(format!("LambdaA2aServerBuilder::run: {e}"))
847        })?;
848        handler.run().await
849    }
850}
851
852#[cfg(test)]
853mod handle_http_event_value_shape_tests {
854    //! Regression tests for the event-shape symmetry contract on
855    //! [`LambdaA2aHandler::handle_http_event_value`]. The wire-shape
856    //! mismatch (v2 response to a v1 invocation) is what causes API
857    //! Gateway REST integrations to return `502 Bad Gateway`. These
858    //! tests pin the contract: response envelope must always match
859    //! the inbound request envelope.
860
861    use super::*;
862    use std::sync::Arc;
863    use turul_a2a::executor::{AgentExecutor, ExecutionContext};
864    use turul_a2a::server::RuntimeConfig;
865    use turul_a2a::server::in_flight::InFlightRegistry;
866    use turul_a2a::storage::InMemoryA2aStorage;
867    use turul_a2a::streaming::TaskEventBroker;
868    use turul_a2a_types::{Message, Task};
869
870    struct NoOpExecutor;
871
872    #[async_trait::async_trait]
873    impl AgentExecutor for NoOpExecutor {
874        async fn execute(
875            &self,
876            task: &mut Task,
877            _msg: &Message,
878            _ctx: &ExecutionContext,
879        ) -> Result<(), turul_a2a::error::A2aError> {
880            let mut proto = task.as_proto().clone();
881            proto.status = Some(turul_a2a_proto::TaskStatus {
882                state: turul_a2a_proto::TaskState::Completed.into(),
883                message: None,
884                timestamp: None,
885            });
886            *task = Task::try_from(proto).unwrap();
887            Ok(())
888        }
889        fn agent_card(&self) -> turul_a2a_proto::AgentCard {
890            turul_a2a_proto::AgentCard {
891                name: "test-agent".to_string(),
892                ..Default::default()
893            }
894        }
895    }
896
897    fn build_handler() -> LambdaA2aHandler {
898        let s = Arc::new(InMemoryA2aStorage::new());
899        let state = AppState {
900            executor: Arc::new(NoOpExecutor),
901            task_storage: s.clone(),
902            push_storage: s.clone(),
903            event_store: s.clone(),
904            atomic_store: s.clone(),
905            event_broker: TaskEventBroker::new(),
906            middleware_stack: Arc::new(MiddlewareStack::new(vec![])),
907            runtime_config: RuntimeConfig::default(),
908            in_flight: Arc::new(InFlightRegistry::new()),
909            cancellation_supervisor: s.clone(),
910            push_delivery_store: None,
911            push_dispatcher: None,
912            durable_executor_queue: None,
913        };
914        let router = build_router(state.clone());
915        LambdaA2aHandler {
916            router,
917            state,
918            path_prefix: None,
919        }
920    }
921
922    /// API Gateway v2 / Function URL event. `requestContext.http.method`
923    /// is what `LambdaRequest::ApiGatewayV2` keys off.
924    fn apigw_v2_agent_card_get() -> serde_json::Value {
925        serde_json::json!({
926            "version": "2.0",
927            "routeKey": "GET /.well-known/agent-card.json",
928            "rawPath": "/.well-known/agent-card.json",
929            "rawQueryString": "",
930            "headers": {"accept": "application/json", "a2a-version": "1.0"},
931            "requestContext": {
932                "accountId": "000000000000",
933                "apiId": "api",
934                "domainName": "fake.execute-api",
935                "domainPrefix": "fake",
936                "http": {
937                    "method": "GET",
938                    "path": "/.well-known/agent-card.json",
939                    "protocol": "HTTP/1.1",
940                    "sourceIp": "127.0.0.1",
941                    "userAgent": "test"
942                },
943                "requestId": "rid",
944                "routeKey": "GET /.well-known/agent-card.json",
945                "stage": "$default",
946                "time": "01/Jan/2026:00:00:00 +0000",
947                "timeEpoch": 1_735_689_600_000i64
948            },
949            "isBase64Encoded": false
950        })
951    }
952
953    /// API Gateway v1 (REST) event. Top-level `httpMethod` + `path` and
954    /// `requestContext.identity` (no `requestContext.http`) is what
955    /// `LambdaRequest::ApiGatewayV1` keys off. This is the shape REST
956    /// API + AWS_PROXY integrations send into the Lambda — the live
957    /// fleet topology that 502'd against a hardcoded v2 response.
958    ///
959    /// `stage` is `$default` here so `lambda_http` does not prepend a
960    /// stage prefix — this fixture exists to pin the response-shape
961    /// contract, not the path-rewriting behaviour. See the
962    /// `strip_path_prefix_*` tests for stage-prefix coverage.
963    fn apigw_v1_agent_card_get_with_stage(stage: &str, path: &str) -> serde_json::Value {
964        let request_context_path = if stage == "$default" {
965            path.to_string()
966        } else {
967            format!("/{stage}{path}")
968        };
969        serde_json::json!({
970            "resource": "/{proxy+}",
971            "path": path,
972            "httpMethod": "GET",
973            "headers": {"accept": "application/json", "a2a-version": "1.0"},
974            "multiValueHeaders": {},
975            "queryStringParameters": null,
976            "multiValueQueryStringParameters": null,
977            "pathParameters": {"proxy": path.trim_start_matches('/').to_string()},
978            "stageVariables": null,
979            "requestContext": {
980                "accountId": "000000000000",
981                "apiId": "abc123",
982                "domainName": "fake.execute-api",
983                "domainPrefix": "fake",
984                "extendedRequestId": "xrid",
985                "httpMethod": "GET",
986                "identity": {
987                    "sourceIp": "127.0.0.1",
988                    "userAgent": "test"
989                },
990                "path": request_context_path,
991                "protocol": "HTTP/1.1",
992                "requestId": "rid",
993                "requestTime": "01/Jan/2026:00:00:00 +0000",
994                "requestTimeEpoch": 1_735_689_600_000i64,
995                "resourceId": "rsrc",
996                "resourcePath": "/{proxy+}",
997                "stage": stage
998            },
999            "body": null,
1000            "isBase64Encoded": false
1001        })
1002    }
1003
1004    fn apigw_v1_agent_card_get() -> serde_json::Value {
1005        apigw_v1_agent_card_get_with_stage("$default", "/.well-known/agent-card.json")
1006    }
1007
1008    /// ALB target-group event. `requestContext.elb` is what
1009    /// `LambdaRequest::Alb` keys off.
1010    fn alb_agent_card_get() -> serde_json::Value {
1011        serde_json::json!({
1012            "requestContext": {
1013                "elb": {
1014                    "targetGroupArn": "arn:aws:elasticloadbalancing:ap-southeast-2:000:targetgroup/fake/00"
1015                }
1016            },
1017            "httpMethod": "GET",
1018            "path": "/.well-known/agent-card.json",
1019            "queryStringParameters": {},
1020            "headers": {"accept": "application/json", "a2a-version": "1.0"},
1021            "body": "",
1022            "isBase64Encoded": false
1023        })
1024    }
1025
1026    /// REST v1 inbound MUST produce a v1 response shape. This is the
1027    /// regression for the live-fleet 502.
1028    #[tokio::test]
1029    async fn v1_inbound_event_emits_v1_response_envelope() {
1030        let handler = build_handler();
1031        let resp_value = handler
1032            .handle_http_event_value(apigw_v1_agent_card_get())
1033            .await
1034            .expect("handle_http_event_value(v1) must succeed");
1035
1036        // Parses cleanly as v1.
1037        let v1: aws_lambda_events::apigw::ApiGatewayProxyResponse =
1038            serde_json::from_value(resp_value.clone())
1039                .expect("response must deserialize as ApiGatewayProxyResponse (v1)");
1040        assert_eq!(v1.status_code, 200);
1041        match v1.body {
1042            Some(aws_lambda_events::encodings::Body::Text(s)) => {
1043                assert!(s.contains("\"name\""), "v1 body shape: {s}");
1044            }
1045            other => panic!("expected text body, got {other:?}"),
1046        }
1047
1048        // Negative: v1 response JSON must NOT contain the v2-only
1049        // `cookies` field. Presence of `cookies` is API Gateway REST's
1050        // 502 trigger.
1051        assert!(
1052            resp_value.get("cookies").is_none(),
1053            "v1 response must not carry v2-only `cookies` field; payload: {resp_value}"
1054        );
1055    }
1056
1057    /// API Gateway v2 / Function URL inbound MUST produce a v2 response
1058    /// shape (current behaviour, locked in to prevent the inverse
1059    /// regression).
1060    #[tokio::test]
1061    async fn v2_inbound_event_emits_v2_response_envelope() {
1062        let handler = build_handler();
1063        let resp_value = handler
1064            .handle_http_event_value(apigw_v2_agent_card_get())
1065            .await
1066            .expect("handle_http_event_value(v2) must succeed");
1067
1068        let v2: aws_lambda_events::apigw::ApiGatewayV2httpResponse =
1069            serde_json::from_value(resp_value.clone())
1070                .expect("response must deserialize as ApiGatewayV2httpResponse (v2)");
1071        assert_eq!(v2.status_code, 200);
1072        match v2.body {
1073            Some(aws_lambda_events::encodings::Body::Text(s)) => {
1074                assert!(s.contains("\"name\""), "v2 body shape: {s}");
1075            }
1076            other => panic!("expected text body, got {other:?}"),
1077        }
1078    }
1079
1080    /// ALB inbound MUST produce an ALB response shape.
1081    #[tokio::test]
1082    async fn alb_inbound_event_emits_alb_response_envelope() {
1083        let handler = build_handler();
1084        let resp_value = handler
1085            .handle_http_event_value(alb_agent_card_get())
1086            .await
1087            .expect("handle_http_event_value(alb) must succeed");
1088
1089        let alb: aws_lambda_events::alb::AlbTargetGroupResponse =
1090            serde_json::from_value(resp_value)
1091                .expect("response must deserialize as AlbTargetGroupResponse");
1092        assert_eq!(alb.status_code, 200);
1093    }
1094
1095    /// Live-fleet end-to-end regression: REST v1 stage-prefixed event
1096    /// + `strip_path_prefix("/dev")` on the handler routes through
1097    /// the agent-card endpoint AND emits a v1 response envelope. This
1098    /// is the exact scenario the live fleet was 502'ing on.
1099    #[tokio::test]
1100    async fn v1_stage_prefix_inbound_with_strip_emits_v1_response() {
1101        let s = Arc::new(InMemoryA2aStorage::new());
1102        let state = AppState {
1103            executor: Arc::new(NoOpExecutor),
1104            task_storage: s.clone(),
1105            push_storage: s.clone(),
1106            event_store: s.clone(),
1107            atomic_store: s.clone(),
1108            event_broker: TaskEventBroker::new(),
1109            middleware_stack: Arc::new(MiddlewareStack::new(vec![])),
1110            runtime_config: RuntimeConfig::default(),
1111            in_flight: Arc::new(InFlightRegistry::new()),
1112            cancellation_supervisor: s.clone(),
1113            push_delivery_store: None,
1114            push_dispatcher: None,
1115            durable_executor_queue: None,
1116        };
1117        let router = build_router(state.clone());
1118        let handler = LambdaA2aHandler {
1119            router,
1120            state,
1121            path_prefix: Some(Arc::from("/dev")),
1122        };
1123
1124        let event = apigw_v1_agent_card_get_with_stage("dev", "/.well-known/agent-card.json");
1125        let resp_value = handler
1126            .handle_http_event_value(event)
1127            .await
1128            .expect("v1 stage-prefixed event must succeed under strip_path_prefix");
1129
1130        let v1: aws_lambda_events::apigw::ApiGatewayProxyResponse =
1131            serde_json::from_value(resp_value)
1132                .expect("response must deserialize as ApiGatewayProxyResponse");
1133        assert_eq!(v1.status_code, 200);
1134        match v1.body {
1135            Some(aws_lambda_events::encodings::Body::Text(s)) => {
1136                assert!(s.contains("\"name\""), "v1 stage-prefix body: {s}");
1137            }
1138            other => panic!("expected text body, got {other:?}"),
1139        }
1140    }
1141
1142    /// Cross-shape decoding regression: a v1 response JSON carries
1143    /// `multiValueHeaders` (REST shape) but no `cookies`; a v2 response
1144    /// carries `cookies` but no v1-only `multiValueHeaders` semantics.
1145    /// Confirms the two shapes are wire-distinguishable, which is why
1146    /// the gateway 502s on a mismatch.
1147    #[tokio::test]
1148    async fn v1_response_does_not_match_v2_envelope_strictly() {
1149        let handler = build_handler();
1150        let v1_value = handler
1151            .handle_http_event_value(apigw_v1_agent_card_get())
1152            .await
1153            .unwrap();
1154
1155        // Sanity: v1 response JSON has no v2-only `cookies` field.
1156        assert!(v1_value.get("cookies").is_none());
1157
1158        let v2_value = handler
1159            .handle_http_event_value(apigw_v2_agent_card_get())
1160            .await
1161            .unwrap();
1162        // Sanity: v2 response JSON carries the v2 `cookies` field
1163        // (default empty array — still present on the wire).
1164        assert!(
1165            v2_value.get("cookies").is_some(),
1166            "v2 response should carry the v2-only cookies field"
1167        );
1168    }
1169}