Skip to main content

alien_commands/runtime/
mod.rs

1use std::time::{Duration, Instant};
2
3use alien_core::{presigned::redact_url_for_error, MessagePayload, QueueMessage};
4use alien_error::{AlienError, Context, ContextError, IntoAlienError, IntoAlienErrorDirect};
5use chrono::{DateTime, Utc};
6use tracing::debug;
7
8use crate::{
9    error::{Error, ErrorData, Result},
10    resolve_envelope_urls,
11    types::{BodySpec, CommandResponse, Envelope, LeaseInfo, LeaseRequest, LeaseResponse},
12    PROTOCOL_VERSION,
13};
14
15/// Safety margin subtracted from a lease's expiry when computing a command's
16/// execution budget. Stopping this far before the lease actually expires
17/// guarantees the runtime finishes (or abandons) the command while the lease
18/// is still held, so an expired lease is never redelivered by the manager
19/// while a duplicate is still in flight. Used by the app-owned pull
20/// `Receiver`; twin of the TypeScript receiver's `LEASE_SAFETY_MARGIN_MS`.
21pub const LEASE_SAFETY_MARGIN: Duration = Duration::from_secs(5);
22
23/// Response upload plus final status submission must finish inside the
24/// operator's additional 60-second lease headroom.
25const COMMAND_RESPONSE_SUBMISSION_TIMEOUT: Duration = Duration::from_secs(30);
26
27/// Per-command execution budget: `min(envelope.deadline, lease_expiry −
28/// [`LEASE_SAFETY_MARGIN`])`. The LEASE bound is clamped to now; an
29/// already-past deadline is not — it yields a zero budget and an immediate
30/// `HANDLER_TIMEOUT`, which is the correct outcome for a command delivered
31/// after its deadline. There is
32/// no lease-renew call in the protocol, so the safety-margined lease expiry
33/// always bounds the budget. Twin of the TypeScript receiver's
34/// `commandBudget`.
35pub fn command_budget(
36    deadline: Option<DateTime<Utc>>,
37    lease_expires_at: DateTime<Utc>,
38) -> DateTime<Utc> {
39    let margin = chrono::Duration::from_std(LEASE_SAFETY_MARGIN)
40        .unwrap_or_else(|_| chrono::Duration::seconds(5));
41    let lease_bound = (lease_expires_at - margin).max(Utc::now());
42    deadline.map_or(lease_bound, |d| d.min(lease_bound))
43}
44
45/// Parse a QueueMessage to extract a command envelope if present
46pub fn parse_envelope(message: &QueueMessage) -> Result<Option<Envelope>> {
47    let envelope_data = match &message.payload {
48        MessagePayload::Json(value) => {
49            // Try to parse as command envelope
50            match serde_json::from_value::<Envelope>(value.clone()) {
51                Ok(envelope) => envelope,
52                Err(_) => return Ok(None), // Not a command envelope
53            }
54        }
55        MessagePayload::Text(text) => {
56            // Try to parse JSON text as command envelope
57            match serde_json::from_str::<Envelope>(text) {
58                Ok(envelope) => envelope,
59                Err(_) => return Ok(None), // Not a command envelope
60            }
61        }
62    };
63
64    // Validate it's a valid command envelope
65    if envelope_data.protocol != PROTOCOL_VERSION {
66        return Ok(None);
67    }
68
69    envelope_data
70        .validate()
71        .context(ErrorData::InvalidEnvelope {
72            message: "Envelope validation failed".to_string(),
73            field: None,
74        })?;
75    Ok(Some(envelope_data))
76}
77
78/// Decode params from an envelope to JSON
79///
80/// For inline params, decodes the base64 and parses as JSON.
81/// For storage params, fetches from storage using the presigned request.
82pub async fn decode_params(envelope: &Envelope) -> Result<serde_json::Value> {
83    match &envelope.params {
84        BodySpec::Inline { inline_base64 } => {
85            use base64::{engine::general_purpose, Engine as _};
86
87            let bytes = general_purpose::STANDARD
88                .decode(inline_base64)
89                .into_alien_error()
90                .context(ErrorData::InvalidEnvelope {
91                    message: "Failed to decode base64 params".to_string(),
92                    field: Some("params.inlineBase64".to_string()),
93                })?;
94
95            serde_json::from_slice(&bytes)
96                .into_alien_error()
97                .context(ErrorData::InvalidEnvelope {
98                    message: "Failed to parse params JSON".to_string(),
99                    field: Some("params".to_string()),
100                })
101        }
102        BodySpec::Storage {
103            storage_get_request,
104            ..
105        } => {
106            let presigned_request = storage_get_request.as_ref().ok_or_else(|| {
107                AlienError::new(ErrorData::InvalidEnvelope {
108                    message: "Storage params missing storage_get_request".to_string(),
109                    field: Some("params.storageGetRequest".to_string()),
110                })
111            })?;
112
113            let response = presigned_request.execute(None).await.context(
114                ErrorData::StorageOperationFailed {
115                    message: "Failed to fetch params from storage".to_string(),
116                    operation: Some("get".to_string()),
117                    path: Some(presigned_request.path.clone()),
118                },
119            )?;
120
121            let body = response.body.ok_or_else(|| {
122                AlienError::new(ErrorData::StorageOperationFailed {
123                    message: "Storage response has no body".to_string(),
124                    operation: Some("get".to_string()),
125                    path: Some(presigned_request.path.clone()),
126                })
127            })?;
128
129            serde_json::from_slice(&body)
130                .into_alien_error()
131                .context(ErrorData::InvalidEnvelope {
132                    message: "Failed to parse params JSON from storage".to_string(),
133                    field: Some("params".to_string()),
134                })
135        }
136    }
137}
138
139/// Decode params from an envelope to raw bytes
140///
141/// For inline params, decodes the base64.
142/// For storage params, fetches from storage using the presigned request.
143pub async fn decode_params_bytes(envelope: &Envelope) -> Result<Vec<u8>> {
144    match &envelope.params {
145        BodySpec::Inline { inline_base64 } => {
146            use base64::{engine::general_purpose, Engine as _};
147
148            general_purpose::STANDARD
149                .decode(inline_base64)
150                .into_alien_error()
151                .context(ErrorData::InvalidEnvelope {
152                    message: "Failed to decode base64 params".to_string(),
153                    field: Some("params.inlineBase64".to_string()),
154                })
155        }
156        BodySpec::Storage {
157            storage_get_request,
158            ..
159        } => {
160            let presigned_request = storage_get_request.as_ref().ok_or_else(|| {
161                AlienError::new(ErrorData::InvalidEnvelope {
162                    message: "Storage params missing storage_get_request".to_string(),
163                    field: Some("params.storageGetRequest".to_string()),
164                })
165            })?;
166
167            let response = presigned_request.execute(None).await.context(
168                ErrorData::StorageOperationFailed {
169                    message: "Failed to fetch params from storage".to_string(),
170                    operation: Some("get".to_string()),
171                    path: Some(presigned_request.path.clone()),
172                },
173            )?;
174
175            response.body.map(|b| b.to_vec()).ok_or_else(|| {
176                AlienError::new(ErrorData::StorageOperationFailed {
177                    message: "Storage response has no body".to_string(),
178                    operation: Some("get".to_string()),
179                    path: Some(presigned_request.path.clone()),
180                })
181            })
182        }
183    }
184}
185
186/// Submit a command response back to the command server
187///
188/// This function implements the complete command response submission protocol:
189/// - Small responses (≤ maxInlineBytes) are submitted inline as base64
190/// - Large responses are uploaded to storage first, then submitted with storage reference
191#[cfg(any(feature = "runtime", feature = "receiver"))]
192pub async fn submit_response(envelope: &Envelope, response: CommandResponse) -> Result<()> {
193    submit_response_with_timeout(envelope, response, COMMAND_RESPONSE_SUBMISSION_TIMEOUT).await
194}
195
196/// Submit a command response without running beyond an absolute lease expiry.
197///
198/// The normal 30-second submission cap still applies, but a receiver holding a
199/// shorter remaining lease must use that smaller budget. Computing the
200/// remaining duration here, immediately before the upload/submission flow,
201/// prevents either stage from extending the lease deadline.
202#[cfg(feature = "receiver")]
203pub(crate) async fn submit_response_before(
204    envelope: &Envelope,
205    response: CommandResponse,
206    lease_expires_at: DateTime<Utc>,
207) -> Result<()> {
208    let remaining_lease = (lease_expires_at - Utc::now())
209        .to_std()
210        .unwrap_or(Duration::ZERO);
211    submit_response_with_timeout(
212        envelope,
213        response,
214        COMMAND_RESPONSE_SUBMISSION_TIMEOUT.min(remaining_lease),
215    )
216    .await
217}
218
219#[cfg(any(feature = "runtime", feature = "receiver"))]
220async fn submit_response_with_timeout(
221    envelope: &Envelope,
222    response: CommandResponse,
223    timeout: Duration,
224) -> Result<()> {
225    let operation = async {
226        let start_time = Instant::now();
227        let client = reqwest::Client::builder()
228            .timeout(COMMAND_RESPONSE_SUBMISSION_TIMEOUT)
229            .pool_max_idle_per_host(2)
230            .pool_idle_timeout(Some(Duration::from_secs(60)))
231            .build()
232            .into_alien_error()
233            .context(ErrorData::Other {
234                message: "Failed to create HTTP client".to_string(),
235            })?;
236        let (final_response, mut pending_upload) = prepare_response_submission(envelope, response)?;
237        let mut retry_delay = Duration::from_millis(100);
238        loop {
239            match submit_response_attempt(&client, envelope, &final_response, &mut pending_upload)
240                .await
241            {
242                Ok(()) => {
243                    debug!(
244                        command_id = %envelope.command_id,
245                        processing_ms = start_time.elapsed().as_millis(),
246                        response_type = if final_response.is_success() { "success" } else { "error" },
247                        "Command response submitted successfully"
248                    );
249                    return Ok(());
250                }
251                Err(SubmissionAttemptError::Permanent(error)) => return Err(error),
252                Err(SubmissionAttemptError::Retryable(error)) => {
253                    debug!(
254                        command_id = %envelope.command_id,
255                        error_code = %error.code,
256                        retry_delay_ms = retry_delay.as_millis(),
257                        "Transient command response submission failure; retrying"
258                    );
259                    tokio::time::sleep(retry_delay).await;
260                    retry_delay = (retry_delay * 2).min(Duration::from_secs(1));
261                }
262            }
263        }
264    };
265
266    match tokio::time::timeout(timeout, operation).await {
267        Ok(result) => result,
268        Err(error) => Err(error
269            .into_alien_error()
270            .context(ErrorData::HttpOperationFailed {
271                message: format!(
272                    "Command response upload/submission exceeded its {} ms headroom budget",
273                    timeout.as_millis()
274                ),
275                method: None,
276                url: Some(redact_url_for_error(
277                    &envelope.response_handling.submit_response_url,
278                )),
279            })),
280    }
281}
282
283#[cfg(any(feature = "runtime", feature = "receiver"))]
284enum SubmissionAttemptError {
285    Retryable(Error),
286    Permanent(Error),
287}
288
289#[cfg(any(feature = "runtime", feature = "receiver"))]
290fn prepare_response_submission(
291    envelope: &Envelope,
292    response: CommandResponse,
293) -> Result<(CommandResponse, Option<bytes::Bytes>)> {
294    match &response {
295        CommandResponse::Success { response: body } => {
296            let body_size = body.size().unwrap_or(0);
297
298            if body_size > envelope.response_handling.max_inline_bytes {
299                let body_bytes = body.decode_inline().ok_or_else(|| {
300                    AlienError::new(ErrorData::Other {
301                        message: "Cannot upload storage body - expected inline body".to_string(),
302                    })
303                })?;
304                Ok((
305                    CommandResponse::Success {
306                        response: BodySpec::Storage {
307                            size: Some(body_bytes.len() as u64),
308                            storage_get_request: None,
309                            storage_put_used: Some(true),
310                        },
311                    },
312                    Some(bytes::Bytes::from(body_bytes)),
313                ))
314            } else {
315                Ok((response, None))
316            }
317        }
318        CommandResponse::Error { .. } => Ok((response, None)),
319    }
320}
321
322#[cfg(any(feature = "runtime", feature = "receiver"))]
323async fn submit_response_attempt(
324    client: &reqwest::Client,
325    envelope: &Envelope,
326    final_response: &CommandResponse,
327    pending_upload: &mut Option<bytes::Bytes>,
328) -> std::result::Result<(), SubmissionAttemptError> {
329    if let Some(body_bytes) = pending_upload.as_ref() {
330        debug!(
331            command_id = %envelope.command_id,
332            body_size = body_bytes.len(),
333            max_inline = envelope.response_handling.max_inline_bytes,
334            "Uploading large response body to storage"
335        );
336
337        let upload_result = envelope
338            .response_handling
339            .storage_upload_request
340            .execute_with_client(client, Some(body_bytes.clone()))
341            .await;
342        let upload_response = match upload_result {
343            Ok(response) => response,
344            Err(error) => {
345                let retryable = error.retryable;
346                let error = error.context(ErrorData::StorageOperationFailed {
347                    message: "Failed to upload response to storage".to_string(),
348                    operation: Some("put".to_string()),
349                    path: Some(
350                        envelope
351                            .response_handling
352                            .storage_upload_request
353                            .path
354                            .clone(),
355                    ),
356                });
357                return Err(if retryable {
358                    SubmissionAttemptError::Retryable(error)
359                } else {
360                    SubmissionAttemptError::Permanent(error)
361                });
362            }
363        };
364
365        if upload_response.status_code < 200 || upload_response.status_code >= 300 {
366            let status = upload_response.status_code;
367            let error = AlienError::new(ErrorData::StorageOperationFailed {
368                message: format!("Storage upload failed with status {}", status),
369                operation: Some("put".to_string()),
370                path: Some(
371                    envelope
372                        .response_handling
373                        .storage_upload_request
374                        .path
375                        .clone(),
376                ),
377            });
378            return Err(if status == 408 || status == 429 || status >= 500 {
379                SubmissionAttemptError::Retryable(error)
380            } else {
381                SubmissionAttemptError::Permanent(error)
382            });
383        }
384
385        debug!(
386            command_id = %envelope.command_id,
387            upload_status = upload_response.status_code,
388            "Response body uploaded to storage successfully"
389        );
390        *pending_upload = None;
391    }
392
393    // Submit response to command server using the URL from the envelope
394    let submit_url = &envelope.response_handling.submit_response_url;
395    let safe_submit_url = redact_url_for_error(submit_url);
396
397    debug!(
398        command_id = %envelope.command_id,
399        url = %safe_submit_url,
400        "Submitting command response"
401    );
402
403    let http_response = client
404        .put(submit_url)
405        .json(&crate::types::SubmitResponseRequest {
406            response: final_response.clone(),
407        })
408        .send()
409        .await;
410    let http_response =
411        match http_response {
412            Ok(response) => response,
413            Err(error) => {
414                let retryable = !error.is_builder();
415                let error = error.without_url().into_alien_error().context(
416                    ErrorData::HttpOperationFailed {
417                        message: "Failed to submit response".to_string(),
418                        method: Some("PUT".to_string()),
419                        url: Some(safe_submit_url.clone()),
420                    },
421                );
422                return Err(if retryable {
423                    SubmissionAttemptError::Retryable(error)
424                } else {
425                    SubmissionAttemptError::Permanent(error)
426                });
427            }
428        };
429
430    if !http_response.status().is_success()
431        && http_response.status() != reqwest::StatusCode::CONFLICT
432        && http_response.status() != reqwest::StatusCode::GONE
433    {
434        let status = http_response.status();
435        let error = AlienError::new(ErrorData::HttpOperationFailed {
436            // A failing endpoint may echo the bearer-equivalent response
437            // token or another signed URL, so its body is not diagnostic-safe.
438            message: format!("Response submission failed with status {status}"),
439            method: Some("PUT".to_string()),
440            url: Some(safe_submit_url),
441        });
442        return Err(
443            if status == reqwest::StatusCode::REQUEST_TIMEOUT
444                || status == reqwest::StatusCode::TOO_MANY_REQUESTS
445                || status.is_server_error()
446            {
447                SubmissionAttemptError::Retryable(error)
448            } else {
449                SubmissionAttemptError::Permanent(error)
450            },
451        );
452    }
453
454    Ok(())
455}
456
457/// Shared lease-acquisition client for app-owned pull receivers.
458///
459/// Holds the fully-qualified `…/commands/leases` endpoint, the bearer token,
460/// and a pooled HTTP client. The endpoint is built **once** at construction
461/// via [`LeaseClient::from_base`], so a base URL that cannot be a hierarchical
462/// (HTTP(S)) URL fails at startup rather than being re-derived — and
463/// misclassified as a transient error — on every poll.
464///
465/// [`LeaseClient::acquire`] returns this crate's [`Result`]; each caller maps
466/// it to its own error enum at the boundary.
467#[cfg(any(feature = "runtime", feature = "receiver"))]
468#[derive(Debug, Clone)]
469pub struct LeaseClient {
470    client: reqwest::Client,
471    endpoint: reqwest::Url,
472    token: String,
473}
474
475#[cfg(any(feature = "runtime", feature = "receiver"))]
476impl LeaseClient {
477    /// Build a lease client for a command-server base URL, appending the
478    /// `commands/leases` path segments once.
479    ///
480    /// Returns `None` if `base` cannot be a hierarchical (HTTP(S)) URL —
481    /// callers surface that as their own startup config error so the permanent
482    /// misconfiguration fails fast instead of being retried on every poll.
483    pub fn from_base(base: &reqwest::Url, token: String) -> Option<Self> {
484        let mut endpoint = base.clone();
485        endpoint
486            .path_segments_mut()
487            .ok()?
488            .pop_if_empty()
489            .push("commands")
490            .push("leases");
491        // A request timeout is load-bearing here: the poll loop awaits this
492        // client serially, so a single hung acquire (half-open TCP, stalled
493        // LB) with reqwest's no-timeout default would freeze command intake
494        // for the whole process, not just one request.
495        let client = reqwest::Client::builder()
496            .timeout(Duration::from_secs(30))
497            .build()
498            .ok()?;
499        Some(Self {
500            client,
501            endpoint,
502            token,
503        })
504    }
505
506    /// The fully-qualified lease endpoint this client POSTs to.
507    pub fn endpoint(&self) -> &reqwest::Url {
508        &self.endpoint
509    }
510
511    /// Acquire leases: POST `request` with the bearer token and parse the
512    /// `LeaseResponse`. Transport, 408, 429, and 5xx failures are retryable;
513    /// other non-success statuses are permanent request rejections.
514    pub async fn acquire(&self, request: &LeaseRequest) -> Result<Vec<LeaseInfo>> {
515        self.acquire_with_token(request, &self.token).await
516    }
517
518    /// Acquire leases with a caller-supplied token. Receivers use this for
519    /// file-backed token rotation; [`Self::acquire`] uses the configured token.
520    pub async fn acquire_with_token(
521        &self,
522        request: &LeaseRequest,
523        token: &str,
524    ) -> Result<Vec<LeaseInfo>> {
525        let response = self
526            .client
527            .post(self.endpoint.clone())
528            .header("Authorization", format!("Bearer {token}"))
529            .json(request)
530            .send()
531            .await
532            .into_alien_error()
533            .context(ErrorData::HttpOperationFailed {
534                message: "Failed to acquire leases".to_string(),
535                method: Some("POST".to_string()),
536                url: Some(self.endpoint.to_string()),
537            })?;
538
539        if response.status() == reqwest::StatusCode::UNAUTHORIZED {
540            return Err(AlienError::new(ErrorData::CommandReceiverUnauthorized {
541                operation: "lease acquisition".to_string(),
542                url: self.endpoint.to_string(),
543            }));
544        }
545
546        if !response.status().is_success() {
547            let status = response.status();
548            if status == reqwest::StatusCode::REQUEST_TIMEOUT
549                || status == reqwest::StatusCode::TOO_MANY_REQUESTS
550                || status.is_server_error()
551            {
552                return Err(AlienError::new(ErrorData::HttpOperationFailed {
553                    message: format!("Lease request failed with status {status}"),
554                    method: Some("POST".to_string()),
555                    url: Some(self.endpoint.to_string()),
556                }));
557            }
558            return Err(AlienError::new(ErrorData::CommandReceiverRequestRejected {
559                operation: "lease acquisition".to_string(),
560                status: status.as_u16(),
561                url: self.endpoint.to_string(),
562            }));
563        }
564
565        let mut lease_response: LeaseResponse =
566            response
567                .json()
568                .await
569                .into_alien_error()
570                .context(ErrorData::SerializationFailed {
571                    message: "Failed to parse lease response".to_string(),
572                    data_type: Some("LeaseResponse".to_string()),
573                })?;
574
575        // Lease-served envelopes carry manager URLs relative to the lease
576        // endpoint. Resolving against the endpoint that succeeded preserves
577        // both its reachable origin and any reverse-proxy path prefix.
578        // Absolute cloud-presigned storage URLs pass through byte-for-byte.
579        for lease in &mut lease_response.leases {
580            resolve_envelope_urls(&mut lease.envelope, &self.endpoint);
581        }
582
583        Ok(lease_response.leases)
584    }
585
586    /// Release a lease during graceful shutdown or duplicate suppression.
587    /// Conflict/gone responses mean the lease is already no longer owned and
588    /// are therefore successful idempotent outcomes.
589    pub async fn release_with_token(&self, lease_id: &str, token: &str) -> Result<()> {
590        let mut endpoint = self.endpoint.clone();
591        let Ok(mut segments) = endpoint.path_segments_mut() else {
592            return Err(AlienError::new(ErrorData::HttpOperationFailed {
593                message: "Commands lease endpoint cannot be extended for release".to_string(),
594                method: Some("POST".to_string()),
595                url: Some(endpoint.to_string()),
596            }));
597        };
598        segments.push(lease_id).push("release");
599        drop(segments);
600        let response = self
601            .client
602            .post(endpoint.clone())
603            .header("Authorization", format!("Bearer {token}"))
604            .json(&crate::types::ReleaseRequest {
605                lease_id: lease_id.to_string(),
606            })
607            .send()
608            .await
609            .into_alien_error()
610            .context(ErrorData::HttpOperationFailed {
611                message: format!("Failed to release lease '{lease_id}'"),
612                method: Some("POST".to_string()),
613                url: Some(endpoint.to_string()),
614            })?;
615
616        if response.status() == reqwest::StatusCode::UNAUTHORIZED {
617            return Err(AlienError::new(ErrorData::CommandReceiverUnauthorized {
618                operation: "lease release".to_string(),
619                url: endpoint.to_string(),
620            }));
621        }
622        if response.status().is_success()
623            || response.status() == reqwest::StatusCode::CONFLICT
624            || response.status() == reqwest::StatusCode::GONE
625        {
626            return Ok(());
627        }
628
629        let status = response.status();
630        Err(AlienError::new(ErrorData::HttpOperationFailed {
631            message: format!("Lease release failed with status {status}"),
632            method: Some("POST".to_string()),
633            url: Some(endpoint.to_string()),
634        }))
635    }
636}
637
638/// Create a simple success response for testing
639pub fn create_test_response(data: &[u8]) -> CommandResponse {
640    CommandResponse::success(data)
641}
642
643/// Create a simple error response for testing
644pub fn create_test_error(code: &str, message: &str) -> CommandResponse {
645    CommandResponse::error(code, message)
646}
647
648#[cfg(test)]
649mod tests {
650    use super::*;
651    use alien_core::presigned::{PresignedOperation, PresignedRequest};
652    use axum::{extract::State, http::StatusCode, routing::post, routing::put, Router};
653    use chrono::Utc;
654    use std::sync::atomic::{AtomicUsize, Ordering};
655    use std::sync::Arc;
656
657    async fn fail_once_then_accept(State(attempts): State<Arc<AtomicUsize>>) -> StatusCode {
658        if attempts.fetch_add(1, Ordering::SeqCst) == 0 {
659            StatusCode::SERVICE_UNAVAILABLE
660        } else {
661            StatusCode::OK
662        }
663    }
664
665    async fn reject_permanently(State(attempts): State<Arc<AtomicUsize>>) -> StatusCode {
666        attempts.fetch_add(1, Ordering::SeqCst);
667        StatusCode::BAD_REQUEST
668    }
669
670    async fn reject_lease_permanently(
671        State(attempts): State<Arc<AtomicUsize>>,
672    ) -> (StatusCode, &'static str) {
673        attempts.fetch_add(1, Ordering::SeqCst);
674        (StatusCode::FORBIDDEN, "sensitive-provider-error-body")
675    }
676
677    async fn reject_lease_transiently(State(attempts): State<Arc<AtomicUsize>>) -> StatusCode {
678        attempts.fetch_add(1, Ordering::SeqCst);
679        StatusCode::SERVICE_UNAVAILABLE
680    }
681
682    #[derive(Clone)]
683    struct UploadThenRetryState {
684        uploads: Arc<AtomicUsize>,
685        submissions: Arc<AtomicUsize>,
686    }
687
688    async fn accept_upload(State(state): State<UploadThenRetryState>) -> StatusCode {
689        state.uploads.fetch_add(1, Ordering::SeqCst);
690        StatusCode::OK
691    }
692
693    async fn fail_first_submission(State(state): State<UploadThenRetryState>) -> StatusCode {
694        if state.submissions.fetch_add(1, Ordering::SeqCst) == 0 {
695            StatusCode::SERVICE_UNAVAILABLE
696        } else {
697            StatusCode::OK
698        }
699    }
700
701    fn create_test_envelope() -> Envelope {
702        Envelope {
703            protocol: PROTOCOL_VERSION.to_string(),
704            target: crate::types::CommandTarget::new(
705                "test-worker",
706                crate::types::CommandTargetType::Worker,
707            ),
708            command_id: "cmd_123".to_string(),
709            attempt: 1,
710            trace_context: None,
711            deadline: None,
712            command: "test-command".to_string(),
713            params: BodySpec::inline(b"{}"),
714            response_handling: crate::types::ResponseHandling {
715                max_inline_bytes: 150000,
716                submit_response_url: "https://commands.example.com/commands/cmd_123/response"
717                    .to_string(),
718                storage_upload_request: PresignedRequest::new_http(
719                    "https://storage.example.com/upload".to_string(),
720                    "PUT".to_string(),
721                    std::collections::HashMap::new(),
722                    PresignedOperation::Put,
723                    "test-path".to_string(),
724                    Utc::now() + chrono::Duration::hours(1),
725                ),
726            },
727            deployment_id: "dep_123".to_string(),
728        }
729    }
730
731    /// Path-relative manager URLs resolve against the exact lease endpoint,
732    /// retaining its reverse-proxy prefix. Absolute cloud-presigned URLs pass
733    /// through byte-for-byte. Twin of the TS `resolveEnvelopeUrls` test.
734    #[test]
735    fn resolve_envelope_urls_resolves_relative_and_keeps_absolute() {
736        let base =
737            reqwest::Url::parse("https://edge.example.com/tenant/v1/commands/leases").unwrap();
738
739        let mut envelope = create_test_envelope();
740        envelope.response_handling.submit_response_url =
741            "cmd_123/response?response_token=t&expires=1".to_string();
742        if let alien_core::presigned::PresignedRequestBackend::Http { url, .. } =
743            &mut envelope.response_handling.storage_upload_request.backend
744        {
745            *url = "../storage/response-blob?sig=x".to_string();
746        }
747        envelope.params = BodySpec::Storage {
748            size: Some(2048),
749            storage_get_request: Some(PresignedRequest::new_http(
750                "cmd_123/params?sig=params".to_string(),
751                "GET".to_string(),
752                std::collections::HashMap::new(),
753                PresignedOperation::Get,
754                "commands/cmd_123/params".to_string(),
755                Utc::now() + chrono::Duration::hours(1),
756            )),
757            storage_put_used: Some(true),
758        };
759
760        resolve_envelope_urls(&mut envelope, &base);
761        assert_eq!(
762            envelope.response_handling.submit_response_url,
763            "https://edge.example.com/tenant/v1/commands/cmd_123/response?response_token=t&expires=1",
764            "relative submit URL must retain the endpoint prefix"
765        );
766        match &envelope.response_handling.storage_upload_request.backend {
767            alien_core::presigned::PresignedRequestBackend::Http { url, .. } => {
768                assert_eq!(
769                    url,
770                    "https://edge.example.com/tenant/v1/storage/response-blob?sig=x"
771                );
772            }
773            other => panic!("unexpected backend: {other:?}"),
774        }
775        let BodySpec::Storage {
776            storage_get_request: Some(params),
777            ..
778        } = &envelope.params
779        else {
780            panic!("storage params request");
781        };
782        assert_eq!(
783            params.url(),
784            "https://edge.example.com/tenant/v1/commands/cmd_123/params?sig=params"
785        );
786
787        // Absolute URLs pass through unchanged, including their signed query.
788        let mut envelope = create_test_envelope();
789        let cloud_url = "https://storage.example.com/upload?X-Signature=DoNotCanonicalize%2FValue";
790        if let alien_core::presigned::PresignedRequestBackend::Http { url, .. } =
791            &mut envelope.response_handling.storage_upload_request.backend
792        {
793            *url = cloud_url.to_string();
794        }
795        resolve_envelope_urls(&mut envelope, &base);
796        assert_eq!(
797            envelope.response_handling.submit_response_url,
798            "https://commands.example.com/commands/cmd_123/response"
799        );
800        assert_eq!(
801            envelope.response_handling.storage_upload_request.url(),
802            cloud_url
803        );
804    }
805
806    #[tokio::test]
807    async fn submit_response_error_does_not_expose_response_token() {
808        let secret = "do-not-log-response-token";
809        let mut envelope = create_test_envelope();
810        envelope.response_handling.submit_response_url =
811            format!("http://127.0.0.1:0/response?response_token={secret}&expires=1");
812
813        let error = submit_response_with_timeout(
814            &envelope,
815            create_test_response(b"ok"),
816            Duration::from_millis(50),
817        )
818        .await
819        .expect_err("port zero must reject the response submission");
820        let serialized = serde_json::to_string(&error).unwrap();
821        let debug = format!("{error:?}");
822
823        assert!(
824            !serialized.contains(secret),
825            "serialized error leaked token"
826        );
827        assert!(!debug.contains(secret), "debug error leaked token");
828        assert!(serialized.contains("http://127.0.0.1:0/response"));
829    }
830
831    #[tokio::test]
832    async fn response_upload_and_submit_share_one_bounded_redacted_timeout() {
833        let upload_secret = "do-not-log-upload-signature";
834        let response_secret = "do-not-log-response-token";
835        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
836        let address = listener.local_addr().unwrap();
837        let server = tokio::spawn(async move {
838            let (_socket, _) = listener.accept().await.unwrap();
839            std::future::pending::<()>().await;
840        });
841
842        let mut envelope = create_test_envelope();
843        envelope.response_handling.max_inline_bytes = 1;
844        envelope.response_handling.submit_response_url =
845            format!("http://{address}/response?response_token={response_secret}");
846        envelope.response_handling.storage_upload_request = PresignedRequest::new_http(
847            format!("http://{address}/upload?signature={upload_secret}"),
848            "PUT".to_string(),
849            std::collections::HashMap::new(),
850            PresignedOperation::Put,
851            "test-path".to_string(),
852            Utc::now() + chrono::Duration::hours(1),
853        );
854
855        let error = submit_response_with_timeout(
856            &envelope,
857            create_test_response(b"large-response"),
858            Duration::from_millis(50),
859        )
860        .await
861        .expect_err("blackholed response upload must time out");
862        let serialized = serde_json::to_string(&error).unwrap();
863        let debug = format!("{error:?}");
864
865        for secret in [upload_secret, response_secret] {
866            assert!(
867                !serialized.contains(secret),
868                "serialized error leaked token"
869            );
870            assert!(!debug.contains(secret), "debug error leaked token");
871        }
872        assert!(serialized.contains(&format!("http://{address}/response")));
873        server.abort();
874    }
875
876    #[tokio::test]
877    async fn transient_final_put_is_retried_within_submission_budget() {
878        let attempts = Arc::new(AtomicUsize::new(0));
879        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
880        let address = listener.local_addr().unwrap();
881        let server_attempts = attempts.clone();
882        let server = tokio::spawn(async move {
883            axum::serve(
884                listener,
885                Router::new()
886                    .route("/response", put(fail_once_then_accept))
887                    .with_state(server_attempts),
888            )
889            .await
890            .unwrap();
891        });
892
893        let mut envelope = create_test_envelope();
894        envelope.response_handling.submit_response_url = format!("http://{address}/response");
895        submit_response_with_timeout(
896            &envelope,
897            create_test_response(b"ok"),
898            Duration::from_secs(2),
899        )
900        .await
901        .expect("second PUT should terminalize the command");
902
903        assert_eq!(attempts.load(Ordering::SeqCst), 2);
904        server.abort();
905    }
906
907    #[tokio::test]
908    async fn retrying_final_put_does_not_repeat_successful_blob_upload() {
909        let state = UploadThenRetryState {
910            uploads: Arc::new(AtomicUsize::new(0)),
911            submissions: Arc::new(AtomicUsize::new(0)),
912        };
913        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
914        let address = listener.local_addr().unwrap();
915        let server_state = state.clone();
916        let server = tokio::spawn(async move {
917            axum::serve(
918                listener,
919                Router::new()
920                    .route("/upload", put(accept_upload))
921                    .route("/response", put(fail_first_submission))
922                    .with_state(server_state),
923            )
924            .await
925            .unwrap();
926        });
927
928        let mut envelope = create_test_envelope();
929        envelope.response_handling.max_inline_bytes = 1;
930        envelope.response_handling.submit_response_url = format!("http://{address}/response");
931        envelope.response_handling.storage_upload_request = PresignedRequest::new_http(
932            format!("http://{address}/upload"),
933            "PUT".to_string(),
934            std::collections::HashMap::new(),
935            PresignedOperation::Put,
936            "test-path".to_string(),
937            Utc::now() + chrono::Duration::hours(1),
938        );
939
940        submit_response_with_timeout(
941            &envelope,
942            create_test_response(b"large-response"),
943            Duration::from_secs(2),
944        )
945        .await
946        .expect("retrying the terminal PUT should not re-upload its blob");
947
948        assert_eq!(state.uploads.load(Ordering::SeqCst), 1);
949        assert_eq!(state.submissions.load(Ordering::SeqCst), 2);
950        server.abort();
951    }
952
953    #[tokio::test]
954    async fn permanent_final_put_rejection_is_not_retried() {
955        let attempts = Arc::new(AtomicUsize::new(0));
956        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
957        let address = listener.local_addr().unwrap();
958        let server_attempts = attempts.clone();
959        let server = tokio::spawn(async move {
960            axum::serve(
961                listener,
962                Router::new()
963                    .route("/response", put(reject_permanently))
964                    .with_state(server_attempts),
965            )
966            .await
967            .unwrap();
968        });
969
970        let mut envelope = create_test_envelope();
971        envelope.response_handling.submit_response_url = format!("http://{address}/response");
972        let error = submit_response_with_timeout(
973            &envelope,
974            create_test_response(b"ok"),
975            Duration::from_secs(2),
976        )
977        .await
978        .expect_err("400 is a permanent submission rejection");
979
980        assert_eq!(attempts.load(Ordering::SeqCst), 1);
981        assert!(error.message.contains("400"));
982        server.abort();
983    }
984
985    fn test_lease_request() -> LeaseRequest {
986        LeaseRequest {
987            deployment_id: "dep_123".to_string(),
988            target: crate::types::CommandTarget::new(
989                "agent",
990                crate::types::CommandTargetType::Daemon,
991            ),
992            max_leases: 1,
993            lease_seconds: 60,
994        }
995    }
996
997    #[tokio::test]
998    async fn permanent_lease_rejection_is_non_retryable_and_redacted() {
999        let attempts = Arc::new(AtomicUsize::new(0));
1000        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1001        let address = listener.local_addr().unwrap();
1002        let server_attempts = attempts.clone();
1003        let server = tokio::spawn(async move {
1004            axum::serve(
1005                listener,
1006                Router::new()
1007                    .route("/commands/leases", post(reject_lease_permanently))
1008                    .with_state(server_attempts),
1009            )
1010            .await
1011            .unwrap();
1012        });
1013        let base = reqwest::Url::parse(&format!("http://{address}")).unwrap();
1014        let client = LeaseClient::from_base(&base, "token".to_string()).unwrap();
1015
1016        let error = client
1017            .acquire(&test_lease_request())
1018            .await
1019            .expect_err("403 must terminate the receiver");
1020
1021        assert_eq!(attempts.load(Ordering::SeqCst), 1);
1022        assert_eq!(error.code, "COMMAND_RECEIVER_REQUEST_REJECTED");
1023        assert!(!error.retryable);
1024        assert!(!serde_json::to_string(&error)
1025            .unwrap()
1026            .contains("sensitive-provider-error-body"));
1027        server.abort();
1028    }
1029
1030    #[tokio::test]
1031    async fn transient_lease_rejection_remains_retryable() {
1032        let attempts = Arc::new(AtomicUsize::new(0));
1033        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1034        let address = listener.local_addr().unwrap();
1035        let server_attempts = attempts.clone();
1036        let server = tokio::spawn(async move {
1037            axum::serve(
1038                listener,
1039                Router::new()
1040                    .route("/commands/leases", post(reject_lease_transiently))
1041                    .with_state(server_attempts),
1042            )
1043            .await
1044            .unwrap();
1045        });
1046        let base = reqwest::Url::parse(&format!("http://{address}")).unwrap();
1047        let client = LeaseClient::from_base(&base, "token".to_string()).unwrap();
1048
1049        let error = client
1050            .acquire(&test_lease_request())
1051            .await
1052            .expect_err("503 must be retryable");
1053
1054        assert_eq!(attempts.load(Ordering::SeqCst), 1);
1055        assert_eq!(error.code, "HTTP_OPERATION_FAILED");
1056        assert!(error.retryable);
1057        server.abort();
1058    }
1059
1060    #[test]
1061    fn test_parse_envelope_json() {
1062        let envelope = create_test_envelope();
1063        let envelope_json = serde_json::to_value(&envelope).unwrap();
1064
1065        let queue_message = QueueMessage {
1066            id: "msg_123".to_string(),
1067            payload: MessagePayload::Json(envelope_json),
1068            receipt_handle: "handle_123".to_string(),
1069            timestamp: Utc::now(),
1070            source: "test-queue".to_string(),
1071            attributes: std::collections::HashMap::new(),
1072            attempt_count: Some(1),
1073        };
1074
1075        let parsed = parse_envelope(&queue_message).unwrap();
1076        assert!(parsed.is_some());
1077
1078        let parsed_envelope = parsed.unwrap();
1079        assert_eq!(parsed_envelope.command_id, "cmd_123");
1080        assert_eq!(parsed_envelope.command, "test-command");
1081        assert_eq!(parsed_envelope.protocol, PROTOCOL_VERSION);
1082    }
1083
1084    #[test]
1085    fn test_parse_envelope_text() {
1086        let envelope = create_test_envelope();
1087        let envelope_text = serde_json::to_string(&envelope).unwrap();
1088
1089        let queue_message = QueueMessage {
1090            id: "msg_456".to_string(),
1091            payload: MessagePayload::Text(envelope_text),
1092            receipt_handle: "handle_456".to_string(),
1093            timestamp: Utc::now(),
1094            source: "test-queue".to_string(),
1095            attributes: std::collections::HashMap::new(),
1096            attempt_count: Some(1),
1097        };
1098
1099        let parsed = parse_envelope(&queue_message).unwrap();
1100        assert!(parsed.is_some());
1101
1102        let parsed_envelope = parsed.unwrap();
1103        assert_eq!(parsed_envelope.command_id, "cmd_123");
1104    }
1105
1106    #[test]
1107    fn test_parse_non_command_message() {
1108        let queue_message = QueueMessage {
1109            id: "msg_789".to_string(),
1110            payload: MessagePayload::Json(serde_json::json!({"regular": "message"})),
1111            receipt_handle: "handle_789".to_string(),
1112            timestamp: Utc::now(),
1113            source: "test-queue".to_string(),
1114            attributes: std::collections::HashMap::new(),
1115            attempt_count: Some(1),
1116        };
1117
1118        let parsed = parse_envelope(&queue_message).unwrap();
1119        assert!(parsed.is_none());
1120    }
1121
1122    #[test]
1123    fn test_parse_invalid_protocol() {
1124        let mut envelope = create_test_envelope();
1125        envelope.protocol = "invalid.v1".to_string();
1126
1127        let envelope_json = serde_json::to_value(&envelope).unwrap();
1128        let queue_message = QueueMessage {
1129            id: "msg_invalid".to_string(),
1130            payload: MessagePayload::Json(envelope_json),
1131            receipt_handle: "handle_invalid".to_string(),
1132            timestamp: Utc::now(),
1133            source: "test-queue".to_string(),
1134            attributes: std::collections::HashMap::new(),
1135            attempt_count: Some(1),
1136        };
1137
1138        let parsed = parse_envelope(&queue_message).unwrap();
1139        assert!(parsed.is_none());
1140    }
1141
1142    #[test]
1143    fn test_create_test_response() {
1144        let response = create_test_response(b"Hello World");
1145        assert!(response.is_success());
1146
1147        if let CommandResponse::Success { response: body } = response {
1148            assert_eq!(body.decode_inline().unwrap(), b"Hello World");
1149        } else {
1150            panic!("Expected success response");
1151        }
1152    }
1153
1154    #[test]
1155    fn test_create_test_error() {
1156        let response = create_test_error("TEST_ERROR", "Something went wrong");
1157        assert!(response.is_error());
1158
1159        if let CommandResponse::Error { code, message, .. } = response {
1160            assert_eq!(code, "TEST_ERROR");
1161            assert_eq!(message, "Something went wrong");
1162        } else {
1163            panic!("Expected error response");
1164        }
1165    }
1166
1167    #[tokio::test]
1168    async fn test_decode_params_inline() {
1169        let params_json = serde_json::json!({"key": "value", "num": 42});
1170        let params_bytes = serde_json::to_vec(&params_json).unwrap();
1171
1172        let envelope = Envelope {
1173            protocol: PROTOCOL_VERSION.to_string(),
1174            target: crate::types::CommandTarget::new(
1175                "test-worker",
1176                crate::types::CommandTargetType::Worker,
1177            ),
1178            command_id: "cmd_decode".to_string(),
1179            attempt: 1,
1180            trace_context: None,
1181            deadline: None,
1182            command: "test".to_string(),
1183            params: BodySpec::inline(&params_bytes),
1184            response_handling: crate::types::ResponseHandling {
1185                max_inline_bytes: 150000,
1186                submit_response_url: "https://commands.example.com/response".to_string(),
1187                storage_upload_request: PresignedRequest::new_http(
1188                    "https://storage.example.com/upload".to_string(),
1189                    "PUT".to_string(),
1190                    std::collections::HashMap::new(),
1191                    PresignedOperation::Put,
1192                    "test-path".to_string(),
1193                    Utc::now() + chrono::Duration::hours(1),
1194                ),
1195            },
1196            deployment_id: "dep_123".to_string(),
1197        };
1198
1199        let decoded = decode_params(&envelope).await.unwrap();
1200        assert_eq!(decoded["key"], "value");
1201        assert_eq!(decoded["num"], 42);
1202    }
1203}