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, and work still running then is aborted
426 // before the invocation returns, so an executor cannot keep
427 // running after a non-blocking send's response. Refuse
428 // `SendMessageConfiguration.return_immediately = true` at the
429 // `core_send_message` entry point (UnsupportedOperationError —
430 // an aspect of SendMessage this runtime cannot honour, A2A
431 // v1.0.1 §3.3.2) via this capability flag instead of silently
432 // orphaning the executor.
433 //
434 // if a durable executor queue is wired via
435 // [`Self::with_durable_executor`] (or the SQS helper), the
436 // capability is re-enabled because enqueueing is durable
437 // across the Lambda freeze. The explicit re-enable lives in
438 // the builder method so the capability cannot be claimed
439 // without the mechanism.
440 let mut runtime_config = self.runtime_config;
441 if self.durable_executor_queue.is_none() {
442 runtime_config.supports_return_immediately = false;
443 }
444 // No background loops run on Lambda, so a lease written here
445 // would never be renewed and peers would fail the live task.
446 runtime_config.executor_lease_ttl = None;
447 runtime_config.executor_stall_timeout = None;
448 // The idle-task sweep needs no loop: the scheduled-recovery
449 // handler runs it.
450 if runtime_config
451 .idle_task_timeout
452 .is_some_and(|d| d.is_zero())
453 {
454 return Err(A2aError::Internal(
455 "idle_task_timeout must be greater than zero".into(),
456 ));
457 }
458
459 // Push dispatcher wiring. When
460 // `push_delivery_store` is present, validate its backend matches,
461 // validate the retry-horizon invariant, and construct the
462 // `PushDispatcher`. Each invocation waits a bounded time for its
463 // fan-out before returning; correctness comes from the atomic
464 // marker + stream/scheduler recovery.
465 let push_dispatcher: Option<Arc<turul_a2a::push::PushDispatcher>> = if let Some(delivery) =
466 push_delivery_store.as_ref()
467 {
468 let push_delivery_backend = delivery.backend_name();
469 if task_backend != push_delivery_backend {
470 return Err(A2aError::Internal(format!(
471 "Storage backend mismatch: task={task_backend}, \
472 push_delivery={push_delivery_backend}. \
473 All storage traits must share the same backend."
474 )));
475 }
476
477 // claim expiry must exceed the worst-case
478 // retry horizon so a live claim is not mis-classified as
479 // stale mid-retry.
480 let retry_horizon = runtime_config
481 .push_backoff_cap
482 .saturating_mul(runtime_config.push_max_attempts as u32);
483 if runtime_config.push_claim_expiry <= retry_horizon {
484 return Err(A2aError::Internal(format!(
485 "push_claim_expiry ({:?}) must be greater than retry horizon \
486 (push_max_attempts={} * push_backoff_cap={:?} = {:?}). \
487 Raise push_claim_expiry or lower push_max_attempts/push_backoff_cap.",
488 runtime_config.push_claim_expiry,
489 runtime_config.push_max_attempts,
490 runtime_config.push_backoff_cap,
491 retry_horizon
492 )));
493 }
494
495 let delivery_cfg =
496 turul_a2a::push::delivery::PushDeliveryConfig::from_runtime_config(&runtime_config);
497
498 let instance_id = format!("a2a-lambda-{}", uuid::Uuid::now_v7());
499 let worker = turul_a2a::push::delivery::PushDeliveryWorker::new(
500 delivery.clone(),
501 delivery_cfg,
502 self.outbound_url_validator.clone(),
503 instance_id,
504 )
505 .map_err(|e| A2aError::Internal(format!("push worker build failed: {e}")))?;
506
507 Some(Arc::new(turul_a2a::push::PushDispatcher::new(
508 Arc::new(worker),
509 push_storage.clone(),
510 task_storage.clone(),
511 )))
512 } else {
513 None
514 };
515
516 let state = AppState {
517 executor,
518 task_storage,
519 push_storage,
520 event_store,
521 atomic_store,
522 event_broker: TaskEventBroker::new(),
523 middleware_stack: Arc::new(MiddlewareStack::new(self.middleware)),
524 runtime_config,
525 in_flight: Arc::new(turul_a2a::server::in_flight::InFlightRegistry::new()),
526 cancellation_supervisor,
527 push_delivery_store,
528 push_dispatcher,
529 durable_executor_queue: self.durable_executor_queue,
530 };
531
532 let router = build_router(state.clone());
533
534 let path_prefix = match self.path_prefix {
535 None => None,
536 Some(p) if p.is_empty() || p == "/" => None,
537 Some(p) => {
538 if !p.starts_with('/') {
539 return Err(A2aError::InvalidRequest {
540 message: format!(
541 "LambdaA2aServerBuilder::strip_path_prefix: prefix must start with '/'; got {p:?}"
542 ),
543 });
544 }
545 if p.ends_with('/') {
546 return Err(A2aError::InvalidRequest {
547 message: format!(
548 "LambdaA2aServerBuilder::strip_path_prefix: prefix must not end with '/' (except '/'); got {p:?}"
549 ),
550 });
551 }
552 Some(Arc::from(p))
553 }
554 };
555
556 Ok(LambdaA2aHandler {
557 router,
558 state,
559 path_prefix,
560 })
561 }
562}
563
564impl Default for LambdaA2aServerBuilder {
565 fn default() -> Self {
566 Self::new()
567 }
568}
569
570/// Lambda handler wrapping the axum Router.
571#[derive(Clone)]
572pub struct LambdaA2aHandler {
573 router: axum::Router,
574 /// Held for SQS-event dispatch (`handle_sqs`) and the idle-task
575 /// sweep (`cancel_idle_tasks`).
576 state: AppState,
577 /// Leading path segment to strip from each incoming HTTP request
578 /// before the A2A router sees it. `None` = no stripping. Set via
579 /// [`LambdaA2aServerBuilder::strip_path_prefix`].
580 path_prefix: Option<Arc<str>>,
581}
582
583impl LambdaA2aHandler {
584 pub fn builder() -> LambdaA2aServerBuilder {
585 LambdaA2aServerBuilder::new()
586 }
587
588 /// Cancel tasks left waiting on their client for longer than the
589 /// runtime configuration's `idle_task_timeout`; see
590 /// [`turul_a2a::server::A2aServerBuilder::idle_task_timeout`]. With no
591 /// timeout set this does nothing. Run by
592 /// [`LambdaScheduledRecoveryHandler::with_idle_task_sweep`]. Returns
593 /// once the push fan-outs of the cancellations have finished or been
594 /// aborted; see [`AppState::settle_detached_work`].
595 pub async fn cancel_idle_tasks(
596 &self,
597 ) -> Result<turul_a2a::server::IdleSweep, turul_a2a::storage::A2aStorageError> {
598 let swept = self.sweep_idle_tasks().await;
599 // The environment can freeze once this returns.
600 self.state.settle_detached_work().await;
601 swept
602 }
603
604 async fn sweep_idle_tasks(
605 &self,
606 ) -> Result<turul_a2a::server::IdleSweep, turul_a2a::storage::A2aStorageError> {
607 match self.state.runtime_config.idle_task_timeout {
608 Some(idle_for) => {
609 turul_a2a::server::liveness::cancel_idle_tasks(
610 &self.state,
611 Some(&self.state.in_flight),
612 idle_for,
613 )
614 .await
615 }
616 None => Ok(Default::default()),
617 }
618 }
619
620 /// Handle a Lambda HTTP request. Returns once the work the request
621 /// left running has finished or been aborted; see
622 /// [`AppState::settle_detached_work`].
623 pub async fn handle(
624 &self,
625 event: lambda_http::Request,
626 ) -> Result<lambda_http::Response<lambda_http::Body>, lambda_http::Error> {
627 let axum_req = lambda_to_axum_request(event)?;
628 let axum_req = match &self.path_prefix {
629 Some(prefix) => adapter::strip_request_path_prefix(axum_req, prefix),
630 None => axum_req,
631 };
632
633 let axum_resp = tower::ServiceExt::oneshot(self.router.clone(), axum_req)
634 .await
635 .map_err(|e| lambda_http::Error::from(format!("Router error: {e}")))?;
636 let resp = axum_to_lambda_response(axum_resp).await;
637 // The environment can freeze once this returns.
638 self.state.settle_detached_work().await;
639 resp
640 }
641
642 /// Handle a raw Lambda HTTP event delivered as `serde_json::Value`
643 /// and return a response envelope **matching the inbound event
644 /// shape**:
645 ///
646 /// | Inbound shape detected by `lambda_http::LambdaRequest` | Response envelope |
647 /// |----------------------------------------------------------|------------------------------------|
648 /// | API Gateway v1 (REST) — top-level `httpMethod` + `path` | `ApiGatewayProxyResponse` |
649 /// | API Gateway v2 / Function URL — `requestContext.http` | `ApiGatewayV2httpResponse` |
650 /// | ALB target group — `requestContext.elb` | `AlbTargetGroupResponse` |
651 /// | WebSocket / unrecognised | `Err(...)` |
652 ///
653 /// Mismatching envelopes — most commonly a v2 response sent in
654 /// response to a v1 invocation — cause API Gateway to return
655 /// `502 Bad Gateway` regardless of the body, because the response
656 /// fails the gateway's contract validation. This method ensures
657 /// symmetry: response shape is always picked from the inbound
658 /// shape, never hardcoded.
659 ///
660 /// This is the framework's canonical HTTP envelope conversion
661 /// primitive — useful when a single Lambda function routes among
662 /// HTTP and one or more non-framework triggers (EventBridge,
663 /// DynamoDB streams, custom invoke) and the adopter drives
664 /// [`lambda_runtime::run`] with `serde_json::Value` directly.
665 /// Handles the `LambdaRequest` deserialization, the text-vs-base64
666 /// content-type detection, and the response envelope rebuild.
667 ///
668 /// Body encoding: text for `text/*`, `application/json`,
669 /// `application/xml`, `application/javascript`, any `+json` / `+xml`
670 /// structured-syntax suffix (RFC 6839, e.g. `application/a2a+json`),
671 /// or anything with `charset=`; base64 otherwise.
672 ///
673 /// Path-prefix stripping (see
674 /// [`LambdaA2aServerBuilder::strip_path_prefix`]) is applied
675 /// because dispatch flows through [`Self::handle`]. Adopters who
676 /// only serve HTTP should continue to use [`Self::run_http_only`]
677 /// or [`LambdaA2aServerBuilder::run`]; this entry point exists for
678 /// composition with adopter-owned third triggers.
679 pub async fn handle_http_event_value(
680 &self,
681 value: serde_json::Value,
682 ) -> Result<serde_json::Value, lambda_runtime::Error> {
683 use base64::Engine;
684 use http_body_util::BodyExt;
685
686 let lambda_req: lambda_http::request::LambdaRequest = serde_json::from_value(value)
687 .map_err(|e| lambda_runtime::Error::from(format!("invalid HTTP event: {e}")))?;
688
689 // Capture the inbound shape so we can emit the matching
690 // response envelope. Lost once we convert to `lambda_http::Request`.
691 let shape = match &lambda_req {
692 lambda_http::request::LambdaRequest::ApiGatewayV1(_) => HttpEventShape::ApiGatewayV1,
693 lambda_http::request::LambdaRequest::ApiGatewayV2(_) => HttpEventShape::ApiGatewayV2,
694 lambda_http::request::LambdaRequest::Alb(_) => HttpEventShape::Alb,
695 lambda_http::request::LambdaRequest::WebSocket(_) => {
696 return Err(lambda_runtime::Error::from(
697 "WebSocket Lambda events are not supported by this adapter",
698 ));
699 }
700 _ => {
701 return Err(lambda_runtime::Error::from(
702 "unsupported Lambda HTTP event variant",
703 ));
704 }
705 };
706
707 let req: lambda_http::Request = lambda_req.into();
708
709 let resp = self
710 .handle(req)
711 .await
712 .map_err(|e| lambda_runtime::Error::from(format!("handler error: {e}")))?;
713 let (parts, body) = resp.into_parts();
714
715 let bytes = body
716 .collect()
717 .await
718 .map_err(|e| lambda_runtime::Error::from(format!("body collect: {e}")))?
719 .to_bytes();
720
721 let ct = parts
722 .headers
723 .get(http::header::CONTENT_TYPE)
724 .and_then(|v| v.to_str().ok())
725 .unwrap_or("application/octet-stream")
726 .to_ascii_lowercase();
727 let essence = ct.split(';').next().unwrap_or("").trim();
728 let is_text = essence.starts_with("text/")
729 || matches!(
730 essence,
731 "application/json" | "application/xml" | "application/javascript"
732 )
733 || essence.ends_with("+json")
734 || essence.ends_with("+xml")
735 || ct.contains("charset=");
736
737 let (body_str, is_base64) = if bytes.is_empty() {
738 (None, false)
739 } else if is_text {
740 match std::str::from_utf8(&bytes) {
741 Ok(s) => (Some(s.to_string()), false),
742 Err(_) => (
743 Some(base64::engine::general_purpose::STANDARD.encode(&bytes)),
744 true,
745 ),
746 }
747 } else {
748 (
749 Some(base64::engine::general_purpose::STANDARD.encode(&bytes)),
750 true,
751 )
752 };
753
754 let status = parts.status.as_u16() as i64;
755 let headers = parts.headers;
756 let body_lambda = body_str.map(aws_lambda_events::encodings::Body::Text);
757
758 match shape {
759 HttpEventShape::ApiGatewayV1 => {
760 let mut api_resp = aws_lambda_events::apigw::ApiGatewayProxyResponse::default();
761 api_resp.status_code = status;
762 api_resp.headers = headers;
763 api_resp.body = body_lambda;
764 api_resp.is_base64_encoded = is_base64;
765 serde_json::to_value(api_resp).map_err(|e| {
766 lambda_runtime::Error::from(format!("serialise APIGW v1 response: {e}"))
767 })
768 }
769 HttpEventShape::ApiGatewayV2 => {
770 let mut api_resp = aws_lambda_events::apigw::ApiGatewayV2httpResponse::default();
771 api_resp.status_code = status;
772 api_resp.headers = headers;
773 api_resp.body = body_lambda;
774 api_resp.is_base64_encoded = is_base64;
775 serde_json::to_value(api_resp).map_err(|e| {
776 lambda_runtime::Error::from(format!("serialise APIGW v2 response: {e}"))
777 })
778 }
779 HttpEventShape::Alb => {
780 let mut api_resp = aws_lambda_events::alb::AlbTargetGroupResponse::default();
781 api_resp.status_code = status;
782 api_resp.headers = headers;
783 api_resp.body = body_lambda;
784 api_resp.is_base64_encoded = is_base64;
785 serde_json::to_value(api_resp).map_err(|e| {
786 lambda_runtime::Error::from(format!("serialise ALB response: {e}"))
787 })
788 }
789 }
790 }
791}
792
793/// Inbound HTTP event shape detected by [`LambdaA2aHandler::handle_http_event_value`].
794///
795/// The response envelope must match the inbound shape — see method
796/// docs for the contract.
797#[derive(Debug, Clone, Copy, PartialEq, Eq)]
798enum HttpEventShape {
799 ApiGatewayV1,
800 ApiGatewayV2,
801 Alb,
802}
803
804impl LambdaA2aHandler {
805 /// Handle an SQS batch from the durable executor queue.
806 ///
807 /// Returns `SqsBatchResponse` whose `batch_item_failures` list
808 /// tells the event source mapping which records to retry.
809 /// Requires `FunctionResponseTypes: ["ReportBatchItemFailures"]`
810 /// on the mapping.
811 ///
812 /// Each record: deserialize envelope → load task → terminal-no-op
813 /// check → cancel-marker direct-CANCELED commit → run executor.
814 /// See `durable::drive_sqs_batch` for the full semantics.
815 #[cfg(feature = "sqs")]
816 pub async fn handle_sqs(
817 &self,
818 event: aws_lambda_events::event::sqs::SqsEvent,
819 ) -> aws_lambda_events::event::sqs::SqsBatchResponse {
820 let resp = durable::drive_sqs_batch(&self.state, event).await;
821 // The environment can freeze once this returns.
822 self.state.settle_detached_work().await;
823 resp
824 }
825
826 /// Run this handler as a pure HTTP Lambda. Strict: any non-HTTP
827 /// event shape (SQS, DynamoDB stream, scheduler, …) causes
828 /// `lambda_http::run` to return a deserialization error on
829 /// invocation.
830 ///
831 /// Appropriate for Lambdas whose only trigger is a Function URL,
832 /// API Gateway, or ALB. The request Lambda in the two-Lambda
833 /// durable-executor topology is the canonical caller.
834 pub async fn run_http_only(self) -> Result<(), lambda_runtime::Error> {
835 let handler = Arc::new(self);
836 lambda_http::run(lambda_http::service_fn(move |event| {
837 let h = Arc::clone(&handler);
838 async move { h.handle(event).await }
839 }))
840 .await
841 }
842}
843
844// Default entrypoint when the `sqs` feature is off — `.run()` aliases
845// the HTTP-only runner. With the `sqs` feature enabled, `.run()` lives
846// in `durable.rs` and routes HTTP+SQS via the classifier.
847#[cfg(not(feature = "sqs"))]
848impl LambdaA2aHandler {
849 /// Default Lambda runner. Dispatches based on the event shape the
850 /// binary receives. Without the `sqs` feature this is HTTP-only
851 /// (equivalent to [`LambdaA2aHandler::run_http_only`]).
852 pub async fn run(self) -> Result<(), lambda_runtime::Error> {
853 self.run_http_only().await
854 }
855}
856
857impl LambdaA2aServerBuilder {
858 /// Build and run in one call. Sugar for
859 /// `self.build()?.run().await`. Hides the handler from the adopter —
860 /// use `.build()` + `handler.run_*()` instead when the handler is
861 /// needed for unit tests, custom middleware, or an explicit
862 /// topology runner.
863 pub async fn run(self) -> Result<(), lambda_runtime::Error> {
864 let handler = self.build().map_err(|e| {
865 lambda_runtime::Error::from(format!("LambdaA2aServerBuilder::run: {e}"))
866 })?;
867 handler.run().await
868 }
869}
870
871#[cfg(test)]
872mod handle_http_event_value_shape_tests {
873 //! Regression tests for the event-shape symmetry contract on
874 //! [`LambdaA2aHandler::handle_http_event_value`]. The wire-shape
875 //! mismatch (v2 response to a v1 invocation) is what causes API
876 //! Gateway REST integrations to return `502 Bad Gateway`. These
877 //! tests pin the contract: response envelope must always match
878 //! the inbound request envelope.
879
880 use super::*;
881 use std::sync::Arc;
882 use turul_a2a::executor::{AgentExecutor, ExecutionContext};
883 use turul_a2a::server::RuntimeConfig;
884 use turul_a2a::server::in_flight::InFlightRegistry;
885 use turul_a2a::storage::InMemoryA2aStorage;
886 use turul_a2a::streaming::TaskEventBroker;
887 use turul_a2a_types::{Message, Task};
888
889 struct NoOpExecutor;
890
891 #[async_trait::async_trait]
892 impl AgentExecutor for NoOpExecutor {
893 async fn execute(
894 &self,
895 task: &mut Task,
896 _msg: &Message,
897 _ctx: &ExecutionContext,
898 ) -> Result<(), turul_a2a::error::A2aError> {
899 let mut proto = task.as_proto().clone();
900 proto.status = Some(turul_a2a_proto::TaskStatus {
901 state: turul_a2a_proto::TaskState::Completed.into(),
902 message: None,
903 timestamp: None,
904 });
905 *task = Task::try_from(proto).unwrap();
906 Ok(())
907 }
908 fn agent_card(&self) -> turul_a2a_proto::AgentCard {
909 turul_a2a_proto::AgentCard {
910 name: "test-agent".to_string(),
911 ..Default::default()
912 }
913 }
914 }
915
916 fn build_handler() -> LambdaA2aHandler {
917 let s = Arc::new(InMemoryA2aStorage::new());
918 let state = AppState {
919 executor: Arc::new(NoOpExecutor),
920 task_storage: s.clone(),
921 push_storage: s.clone(),
922 event_store: s.clone(),
923 atomic_store: s.clone(),
924 event_broker: TaskEventBroker::new(),
925 middleware_stack: Arc::new(MiddlewareStack::new(vec![])),
926 runtime_config: RuntimeConfig::default(),
927 in_flight: Arc::new(InFlightRegistry::new()),
928 cancellation_supervisor: s.clone(),
929 push_delivery_store: None,
930 push_dispatcher: None,
931 durable_executor_queue: None,
932 };
933 let router = build_router(state.clone());
934 LambdaA2aHandler {
935 router,
936 state,
937 path_prefix: None,
938 }
939 }
940
941 /// API Gateway v2 / Function URL event. `requestContext.http.method`
942 /// is what `LambdaRequest::ApiGatewayV2` keys off.
943 fn apigw_v2_agent_card_get() -> serde_json::Value {
944 serde_json::json!({
945 "version": "2.0",
946 "routeKey": "GET /.well-known/agent-card.json",
947 "rawPath": "/.well-known/agent-card.json",
948 "rawQueryString": "",
949 "headers": {"accept": "application/json", "a2a-version": "1.0"},
950 "requestContext": {
951 "accountId": "000000000000",
952 "apiId": "api",
953 "domainName": "fake.execute-api",
954 "domainPrefix": "fake",
955 "http": {
956 "method": "GET",
957 "path": "/.well-known/agent-card.json",
958 "protocol": "HTTP/1.1",
959 "sourceIp": "127.0.0.1",
960 "userAgent": "test"
961 },
962 "requestId": "rid",
963 "routeKey": "GET /.well-known/agent-card.json",
964 "stage": "$default",
965 "time": "01/Jan/2026:00:00:00 +0000",
966 "timeEpoch": 1_735_689_600_000i64
967 },
968 "isBase64Encoded": false
969 })
970 }
971
972 /// API Gateway v1 (REST) event. Top-level `httpMethod` + `path` and
973 /// `requestContext.identity` (no `requestContext.http`) is what
974 /// `LambdaRequest::ApiGatewayV1` keys off. This is the shape REST
975 /// API + AWS_PROXY integrations send into the Lambda — the live
976 /// fleet topology that 502'd against a hardcoded v2 response.
977 ///
978 /// `stage` is `$default` here so `lambda_http` does not prepend a
979 /// stage prefix — this fixture exists to pin the response-shape
980 /// contract, not the path-rewriting behaviour. See the
981 /// `strip_path_prefix_*` tests for stage-prefix coverage.
982 fn apigw_v1_agent_card_get_with_stage(stage: &str, path: &str) -> serde_json::Value {
983 let request_context_path = if stage == "$default" {
984 path.to_string()
985 } else {
986 format!("/{stage}{path}")
987 };
988 serde_json::json!({
989 "resource": "/{proxy+}",
990 "path": path,
991 "httpMethod": "GET",
992 "headers": {"accept": "application/json", "a2a-version": "1.0"},
993 "multiValueHeaders": {},
994 "queryStringParameters": null,
995 "multiValueQueryStringParameters": null,
996 "pathParameters": {"proxy": path.trim_start_matches('/').to_string()},
997 "stageVariables": null,
998 "requestContext": {
999 "accountId": "000000000000",
1000 "apiId": "abc123",
1001 "domainName": "fake.execute-api",
1002 "domainPrefix": "fake",
1003 "extendedRequestId": "xrid",
1004 "httpMethod": "GET",
1005 "identity": {
1006 "sourceIp": "127.0.0.1",
1007 "userAgent": "test"
1008 },
1009 "path": request_context_path,
1010 "protocol": "HTTP/1.1",
1011 "requestId": "rid",
1012 "requestTime": "01/Jan/2026:00:00:00 +0000",
1013 "requestTimeEpoch": 1_735_689_600_000i64,
1014 "resourceId": "rsrc",
1015 "resourcePath": "/{proxy+}",
1016 "stage": stage
1017 },
1018 "body": null,
1019 "isBase64Encoded": false
1020 })
1021 }
1022
1023 fn apigw_v1_agent_card_get() -> serde_json::Value {
1024 apigw_v1_agent_card_get_with_stage("$default", "/.well-known/agent-card.json")
1025 }
1026
1027 /// ALB target-group event. `requestContext.elb` is what
1028 /// `LambdaRequest::Alb` keys off.
1029 fn alb_agent_card_get() -> serde_json::Value {
1030 serde_json::json!({
1031 "requestContext": {
1032 "elb": {
1033 "targetGroupArn": "arn:aws:elasticloadbalancing:ap-southeast-2:000:targetgroup/fake/00"
1034 }
1035 },
1036 "httpMethod": "GET",
1037 "path": "/.well-known/agent-card.json",
1038 "queryStringParameters": {},
1039 "headers": {"accept": "application/json", "a2a-version": "1.0"},
1040 "body": "",
1041 "isBase64Encoded": false
1042 })
1043 }
1044
1045 /// REST v1 inbound MUST produce a v1 response shape. This is the
1046 /// regression for the live-fleet 502.
1047 #[tokio::test]
1048 async fn v1_inbound_event_emits_v1_response_envelope() {
1049 let handler = build_handler();
1050 let resp_value = handler
1051 .handle_http_event_value(apigw_v1_agent_card_get())
1052 .await
1053 .expect("handle_http_event_value(v1) must succeed");
1054
1055 // Parses cleanly as v1.
1056 let v1: aws_lambda_events::apigw::ApiGatewayProxyResponse =
1057 serde_json::from_value(resp_value.clone())
1058 .expect("response must deserialize as ApiGatewayProxyResponse (v1)");
1059 assert_eq!(v1.status_code, 200);
1060 match v1.body {
1061 Some(aws_lambda_events::encodings::Body::Text(s)) => {
1062 assert!(s.contains("\"name\""), "v1 body shape: {s}");
1063 }
1064 other => panic!("expected text body, got {other:?}"),
1065 }
1066
1067 // Negative: v1 response JSON must NOT contain the v2-only
1068 // `cookies` field. Presence of `cookies` is API Gateway REST's
1069 // 502 trigger.
1070 assert!(
1071 resp_value.get("cookies").is_none(),
1072 "v1 response must not carry v2-only `cookies` field; payload: {resp_value}"
1073 );
1074 }
1075
1076 /// API Gateway v2 / Function URL inbound MUST produce a v2 response
1077 /// shape (current behaviour, locked in to prevent the inverse
1078 /// regression).
1079 #[tokio::test]
1080 async fn v2_inbound_event_emits_v2_response_envelope() {
1081 let handler = build_handler();
1082 let resp_value = handler
1083 .handle_http_event_value(apigw_v2_agent_card_get())
1084 .await
1085 .expect("handle_http_event_value(v2) must succeed");
1086
1087 let v2: aws_lambda_events::apigw::ApiGatewayV2httpResponse =
1088 serde_json::from_value(resp_value.clone())
1089 .expect("response must deserialize as ApiGatewayV2httpResponse (v2)");
1090 assert_eq!(v2.status_code, 200);
1091 match v2.body {
1092 Some(aws_lambda_events::encodings::Body::Text(s)) => {
1093 assert!(s.contains("\"name\""), "v2 body shape: {s}");
1094 }
1095 other => panic!("expected text body, got {other:?}"),
1096 }
1097 }
1098
1099 /// ALB inbound MUST produce an ALB response shape.
1100 #[tokio::test]
1101 async fn alb_inbound_event_emits_alb_response_envelope() {
1102 let handler = build_handler();
1103 let resp_value = handler
1104 .handle_http_event_value(alb_agent_card_get())
1105 .await
1106 .expect("handle_http_event_value(alb) must succeed");
1107
1108 let alb: aws_lambda_events::alb::AlbTargetGroupResponse =
1109 serde_json::from_value(resp_value)
1110 .expect("response must deserialize as AlbTargetGroupResponse");
1111 assert_eq!(alb.status_code, 200);
1112 }
1113
1114 /// Live-fleet end-to-end regression: REST v1 stage-prefixed event
1115 /// + `strip_path_prefix("/dev")` on the handler routes through
1116 /// the agent-card endpoint AND emits a v1 response envelope. This
1117 /// is the exact scenario the live fleet was 502'ing on.
1118 #[tokio::test]
1119 async fn v1_stage_prefix_inbound_with_strip_emits_v1_response() {
1120 let s = Arc::new(InMemoryA2aStorage::new());
1121 let state = AppState {
1122 executor: Arc::new(NoOpExecutor),
1123 task_storage: s.clone(),
1124 push_storage: s.clone(),
1125 event_store: s.clone(),
1126 atomic_store: s.clone(),
1127 event_broker: TaskEventBroker::new(),
1128 middleware_stack: Arc::new(MiddlewareStack::new(vec![])),
1129 runtime_config: RuntimeConfig::default(),
1130 in_flight: Arc::new(InFlightRegistry::new()),
1131 cancellation_supervisor: s.clone(),
1132 push_delivery_store: None,
1133 push_dispatcher: None,
1134 durable_executor_queue: None,
1135 };
1136 let router = build_router(state.clone());
1137 let handler = LambdaA2aHandler {
1138 router,
1139 state,
1140 path_prefix: Some(Arc::from("/dev")),
1141 };
1142
1143 let event = apigw_v1_agent_card_get_with_stage("dev", "/.well-known/agent-card.json");
1144 let resp_value = handler
1145 .handle_http_event_value(event)
1146 .await
1147 .expect("v1 stage-prefixed event must succeed under strip_path_prefix");
1148
1149 let v1: aws_lambda_events::apigw::ApiGatewayProxyResponse =
1150 serde_json::from_value(resp_value)
1151 .expect("response must deserialize as ApiGatewayProxyResponse");
1152 assert_eq!(v1.status_code, 200);
1153 match v1.body {
1154 Some(aws_lambda_events::encodings::Body::Text(s)) => {
1155 assert!(s.contains("\"name\""), "v1 stage-prefix body: {s}");
1156 }
1157 other => panic!("expected text body, got {other:?}"),
1158 }
1159 }
1160
1161 /// Cross-shape decoding regression: a v1 response JSON carries
1162 /// `multiValueHeaders` (REST shape) but no `cookies`; a v2 response
1163 /// carries `cookies` but no v1-only `multiValueHeaders` semantics.
1164 /// Confirms the two shapes are wire-distinguishable, which is why
1165 /// the gateway 502s on a mismatch.
1166 #[tokio::test]
1167 async fn v1_response_does_not_match_v2_envelope_strictly() {
1168 let handler = build_handler();
1169 let v1_value = handler
1170 .handle_http_event_value(apigw_v1_agent_card_get())
1171 .await
1172 .unwrap();
1173
1174 // Sanity: v1 response JSON has no v2-only `cookies` field.
1175 assert!(v1_value.get("cookies").is_none());
1176
1177 let v2_value = handler
1178 .handle_http_event_value(apigw_v2_agent_card_get())
1179 .await
1180 .unwrap();
1181 // Sanity: v2 response JSON carries the v2 `cookies` field
1182 // (default empty array — still present on the wire).
1183 assert!(
1184 v2_value.get("cookies").is_some(),
1185 "v2 response should carry the v2-only cookies field"
1186 );
1187 }
1188}