Skip to main content

fakecloud_stepfunctions/
interpreter.rs

1use std::collections::HashMap;
2use std::sync::Arc;
3
4use chrono::Utc;
5use serde_json::{json, Value};
6use tracing::{debug, warn};
7
8use fakecloud_aws::arn::Arn;
9use fakecloud_core::delivery::DeliveryBus;
10use fakecloud_dynamodb::SharedDynamoDbState;
11
12use crate::choice::evaluate_choice;
13use crate::error_handling::{find_catcher, should_retry};
14use crate::io_processing::{apply_input_path, apply_output_path, apply_result_path};
15use crate::service::SharedServiceRegistry;
16use crate::state::{ExecutionStatus, HistoryEvent, MapRun, SharedStepFunctionsState};
17
18/// Execute a state machine definition with the given input.
19/// Updates the execution record in shared state as it progresses.
20#[allow(clippy::too_many_arguments)]
21pub async fn execute_state_machine(
22    state: SharedStepFunctionsState,
23    execution_arn: String,
24    definition: String,
25    input: Option<String>,
26    delivery: Option<Arc<DeliveryBus>>,
27    dynamodb_state: Option<SharedDynamoDbState>,
28    registry: Option<SharedServiceRegistry>,
29    logging_configuration: Option<Value>,
30) {
31    let def: Value = match serde_json::from_str(&definition) {
32        Ok(v) => v,
33        Err(e) => {
34            fail_execution(
35                &state,
36                &execution_arn,
37                "States.Runtime",
38                &format!("Failed to parse definition: {e}"),
39            );
40            return;
41        }
42    };
43
44    let raw_input: Value = input
45        .as_deref()
46        .and_then(|s| serde_json::from_str(s).ok())
47        .unwrap_or(json!({}));
48
49    // Record ExecutionStarted event
50    add_event(
51        &state,
52        &execution_arn,
53        "ExecutionStarted",
54        0,
55        json!({
56            "input": serde_json::to_string(&raw_input).expect("serde_json::Value serialization is infallible"),
57            "roleArn": "arn:aws:iam::123456789012:role/execution-role"
58        }),
59    );
60
61    // Run the state machine inside an inner tokio::spawn so that any panic
62    // bubbles up as a JoinError instead of tearing down the caller. Without
63    // this the panic propagates through the outer spawn in `start_execution`
64    // which leaves the execution stuck in Running and leaks the panic to
65    // tokio's default hook.
66    let def_owned = def;
67    let state_clone = state.clone();
68    let execution_arn_clone = execution_arn.clone();
69    let delivery_clone = delivery.clone();
70    let dynamodb_state_clone = dynamodb_state.clone();
71    let registry_clone = registry.clone();
72    let handle = tokio::spawn(async move {
73        run_states(
74            &def_owned,
75            raw_input,
76            &delivery_clone,
77            &dynamodb_state_clone,
78            &registry_clone,
79            &state_clone,
80            &execution_arn_clone,
81        )
82        .await
83    });
84
85    match handle.await {
86        Ok(Ok(output)) => {
87            succeed_execution(&state, &execution_arn, &output);
88        }
89        Ok(Err((error, cause))) => {
90            fail_execution(&state, &execution_arn, &error, &cause);
91        }
92        Err(join_err) => {
93            let msg = if join_err.is_panic() {
94                let payload = join_err.into_panic();
95                if let Some(s) = payload.downcast_ref::<String>() {
96                    s.clone()
97                } else if let Some(s) = payload.downcast_ref::<&'static str>() {
98                    (*s).to_string()
99                } else {
100                    "execution task panicked".to_string()
101                }
102            } else {
103                format!("execution task cancelled: {join_err}")
104            };
105            tracing::error!(
106                execution_arn = %execution_arn,
107                panic = %msg,
108                "Step Functions execution panicked"
109            );
110            fail_execution(&state, &execution_arn, "States.Runtime", &msg);
111        }
112    }
113
114    // Deliver execution history to CloudWatch Logs when logging is configured.
115    deliver_execution_logs(
116        &state,
117        &execution_arn,
118        delivery.as_ref(),
119        logging_configuration.as_ref(),
120    );
121}
122
123type StatesResult<'a> = std::pin::Pin<
124    Box<dyn std::future::Future<Output = Result<Value, (String, String)>> + Send + 'a>,
125>;
126
127/// Result of executing a single state in the state machine.
128pub(crate) enum Advance {
129    /// Continue to the given state with the given input.
130    Next(String, Value),
131    /// Terminate the state machine with the given output.
132    End(Value),
133    /// Fail the state machine with the given error and cause.
134    Fail(String, String),
135}
136
137async fn run_wait_state(
138    name: &str,
139    state_def: &Value,
140    input: Value,
141    shared_state: &SharedStepFunctionsState,
142    execution_arn: &str,
143) -> Advance {
144    let entered_event_id = add_event(
145        shared_state,
146        execution_arn,
147        "WaitStateEntered",
148        0,
149        json!({
150            "name": name,
151            "input": serde_json::to_string(&input).expect("serde_json::Value serialization is infallible"),
152        }),
153    );
154
155    execute_wait_state(state_def, &input).await;
156
157    add_event(
158        shared_state,
159        execution_arn,
160        "WaitStateExited",
161        entered_event_id,
162        json!({
163            "name": name,
164            "output": serde_json::to_string(&input).expect("serde_json::Value serialization is infallible"),
165        }),
166    );
167
168    advance_from_next(state_def, input)
169}
170
171#[allow(clippy::too_many_arguments)]
172async fn run_task_state(
173    name: &str,
174    state_def: &Value,
175    input: Value,
176    delivery: &Option<Arc<DeliveryBus>>,
177    dynamodb_state: &Option<SharedDynamoDbState>,
178    registry: &Option<SharedServiceRegistry>,
179    shared_state: &SharedStepFunctionsState,
180    execution_arn: &str,
181) -> Advance {
182    let entered_event_id = add_event(
183        shared_state,
184        execution_arn,
185        "TaskStateEntered",
186        0,
187        json!({
188            "name": name,
189            "input": serde_json::to_string(&input).expect("serde_json::Value serialization is infallible"),
190        }),
191    );
192
193    let result = execute_task_state(
194        name,
195        state_def,
196        &input,
197        delivery,
198        dynamodb_state,
199        registry,
200        shared_state,
201        execution_arn,
202        entered_event_id,
203    )
204    .await;
205
206    match result {
207        Ok(output) => {
208            add_event(
209                shared_state,
210                execution_arn,
211                "TaskStateExited",
212                entered_event_id,
213                json!({
214                    "name": name,
215                    "output": serde_json::to_string(&output).expect("serde_json::Value serialization is infallible"),
216                }),
217            );
218            advance_from_next(state_def, output)
219        }
220        // Task-originated error → the States.TaskFailed wildcard applies.
221        Err((error, cause)) => advance_from_error(state_def, &input, error, cause, true),
222    }
223}
224
225#[allow(clippy::too_many_arguments)]
226async fn run_parallel_state(
227    name: &str,
228    state_def: &Value,
229    input: Value,
230    delivery: &Option<Arc<DeliveryBus>>,
231    dynamodb_state: &Option<SharedDynamoDbState>,
232    registry: &Option<SharedServiceRegistry>,
233    shared_state: &SharedStepFunctionsState,
234    execution_arn: &str,
235) -> Advance {
236    let entered_event_id = add_event(
237        shared_state,
238        execution_arn,
239        "ParallelStateEntered",
240        0,
241        json!({
242            "name": name,
243            "input": serde_json::to_string(&input).expect("serde_json::Value serialization is infallible"),
244        }),
245    );
246
247    let result = execute_parallel_state(
248        state_def,
249        &input,
250        delivery,
251        dynamodb_state,
252        registry,
253        shared_state,
254        execution_arn,
255    )
256    .await;
257
258    match result {
259        Ok(output) => {
260            add_event(
261                shared_state,
262                execution_arn,
263                "ParallelStateExited",
264                entered_event_id,
265                json!({
266                    "name": name,
267                    "output": serde_json::to_string(&output).expect("serde_json::Value serialization is infallible"),
268                }),
269            );
270            advance_from_next(state_def, output)
271        }
272        // A Parallel branch failure is not itself a task error; the
273        // States.TaskFailed wildcard must not swallow branch-level errors.
274        Err((error, cause)) => advance_from_error(state_def, &input, error, cause, false),
275    }
276}
277
278#[allow(clippy::too_many_arguments)]
279async fn run_map_state(
280    name: &str,
281    state_def: &Value,
282    input: Value,
283    delivery: &Option<Arc<DeliveryBus>>,
284    dynamodb_state: &Option<SharedDynamoDbState>,
285    registry: &Option<SharedServiceRegistry>,
286    shared_state: &SharedStepFunctionsState,
287    execution_arn: &str,
288) -> Advance {
289    let entered_event_id = add_event(
290        shared_state,
291        execution_arn,
292        "MapStateEntered",
293        0,
294        json!({
295            "name": name,
296            "input": serde_json::to_string(&input).expect("serde_json::Value serialization is infallible"),
297        }),
298    );
299
300    let result = execute_map_state(
301        state_def,
302        &input,
303        delivery,
304        dynamodb_state,
305        registry,
306        shared_state,
307        execution_arn,
308    )
309    .await;
310
311    match result {
312        Ok(output) => {
313            add_event(
314                shared_state,
315                execution_arn,
316                "MapStateExited",
317                entered_event_id,
318                json!({
319                    "name": name,
320                    "output": serde_json::to_string(&output).expect("serde_json::Value serialization is infallible"),
321                }),
322            );
323            advance_from_next(state_def, output)
324        }
325        // A Map iteration failure is not itself a task error; the
326        // States.TaskFailed wildcard must not swallow iteration-level errors.
327        Err((error, cause)) => advance_from_error(state_def, &input, error, cause, false),
328    }
329}
330
331/// Execute a Wait state: pause execution for a specified duration or until a timestamp.
332async fn execute_wait_state(state_def: &Value, input: &Value) {
333    if let Some(seconds) = state_def["Seconds"].as_u64() {
334        tokio::time::sleep(tokio::time::Duration::from_secs(seconds)).await;
335        return;
336    }
337
338    if let Some(path) = state_def["SecondsPath"].as_str() {
339        let val = crate::io_processing::resolve_path(input, path);
340        if let Some(seconds) = val.as_u64() {
341            tokio::time::sleep(tokio::time::Duration::from_secs(seconds)).await;
342        }
343        return;
344    }
345
346    if let Some(ts_str) = state_def["Timestamp"].as_str() {
347        if let Ok(target) = chrono::DateTime::parse_from_rfc3339(ts_str) {
348            let now = Utc::now();
349            let target_utc = target.with_timezone(&chrono::Utc);
350            if target_utc > now {
351                let duration = (target_utc - now).to_std().unwrap_or_default();
352                tokio::time::sleep(duration).await;
353            }
354        }
355        return;
356    }
357
358    if let Some(path) = state_def["TimestampPath"].as_str() {
359        let val = crate::io_processing::resolve_path(input, path);
360        if let Some(ts_str) = val.as_str() {
361            if let Ok(target) = chrono::DateTime::parse_from_rfc3339(ts_str) {
362                let now = Utc::now();
363                let target_utc = target.with_timezone(&chrono::Utc);
364                if target_utc > now {
365                    let duration = (target_utc - now).to_std().unwrap_or_default();
366                    tokio::time::sleep(duration).await;
367                }
368            }
369        }
370        return;
371    }
372
373    warn!(
374        "Wait state has no valid Seconds, SecondsPath, Timestamp, or TimestampPath — skipping wait"
375    );
376}
377
378/// Execute a Task state: invoke the resource (Lambda, SQS, SNS, EventBridge, DynamoDB),
379/// apply I/O processing, handle Retry.
380#[allow(clippy::too_many_arguments)]
381async fn execute_task_state(
382    name: &str,
383    state_def: &Value,
384    input: &Value,
385    delivery: &Option<Arc<DeliveryBus>>,
386    dynamodb_state: &Option<SharedDynamoDbState>,
387    registry: &Option<SharedServiceRegistry>,
388    shared_state: &SharedStepFunctionsState,
389    execution_arn: &str,
390    entered_event_id: i64,
391) -> Result<Value, (String, String)> {
392    let resource = state_def["Resource"].as_str().unwrap_or("").to_string();
393
394    let input_path = state_def["InputPath"].as_str();
395    let result_path = state_def["ResultPath"].as_str();
396    let output_path = state_def["OutputPath"].as_str();
397
398    let effective_input = if input_path == Some("null") {
399        json!({})
400    } else {
401        apply_input_path(input, input_path)
402    };
403
404    let retriers = state_def["Retry"].as_array().cloned().unwrap_or_default();
405    let timeout_seconds = state_def["TimeoutSeconds"].as_u64();
406    let heartbeat_seconds = state_def["HeartbeatSeconds"].as_u64();
407    let mut attempt = 0u32;
408
409    let is_wait_for_task_token = resource.contains(".waitForTaskToken");
410    let task_token = if is_wait_for_task_token {
411        let token = format!(
412            "FCToken-{}-{}",
413            chrono::Utc::now().timestamp_nanos_opt().unwrap_or(0),
414            uuid::Uuid::new_v4().simple(),
415        );
416        let account_id = account_id_from_arn(execution_arn);
417        let context = json!({
418            "Task": { "Token": token.clone() },
419            "Execution": { "Id": execution_arn },
420            "State": { "Name": name },
421        });
422        {
423            let mut accounts = shared_state.write();
424            let state = accounts.get_or_create(account_id);
425            state.task_tokens.insert(
426                token.clone(),
427                crate::state::TaskTokenState {
428                    activity_arn: String::new(),
429                    status: "PENDING".to_string(),
430                    output: None,
431                    error: None,
432                    cause: None,
433                    input: None,
434                    created_at: chrono::Utc::now(),
435                    last_heartbeat_at: None,
436                    heartbeat_seconds: heartbeat_seconds.map(|s| s as i64),
437                    timeout_seconds: timeout_seconds.map(|s| s as i64),
438                },
439            );
440        }
441        Some((token, context))
442    } else {
443        None
444    };
445
446    let task_input = if let Some(params) = state_def.get("Parameters") {
447        if let Some((_, ctx)) = &task_token {
448            apply_parameters(params, &effective_input, Some(ctx))
449        } else {
450            apply_parameters(params, &effective_input, None)
451        }
452    } else {
453        effective_input
454    };
455
456    loop {
457        add_event(
458            shared_state,
459            execution_arn,
460            "TaskScheduled",
461            entered_event_id,
462            json!({
463                "resource": resource,
464                "region": "us-east-1",
465                "parameters": serde_json::to_string(&task_input).expect("serde_json::Value serialization is infallible"),
466            }),
467        );
468
469        add_event(
470            shared_state,
471            execution_arn,
472            "TaskStarted",
473            entered_event_id,
474            json!({ "resource": resource }),
475        );
476
477        let invoke_result = invoke_resource(
478            &resource,
479            &task_input,
480            delivery,
481            dynamodb_state,
482            registry,
483            execution_arn,
484            timeout_seconds,
485            heartbeat_seconds,
486            shared_state,
487        )
488        .await;
489
490        match invoke_result {
491            Ok(result) => {
492                if let Some((token, _)) = &task_token {
493                    let account_id = account_id_from_arn(execution_arn);
494                    match poll_task_token(
495                        shared_state,
496                        account_id,
497                        token,
498                        timeout_seconds,
499                        heartbeat_seconds,
500                    )
501                    .await
502                    {
503                        Ok(output) => {
504                            add_event(
505                                shared_state,
506                                execution_arn,
507                                "TaskSucceeded",
508                                entered_event_id,
509                                json!({
510                                    "resource": resource,
511                                    "output": serde_json::to_string(&output).expect("serde_json::Value serialization is infallible"),
512                                }),
513                            );
514
515                            let selected = if let Some(selector) = state_def.get("ResultSelector") {
516                                apply_parameters(selector, &output, None)
517                            } else {
518                                output
519                            };
520
521                            let after_result = if result_path == Some("null") {
522                                input.clone()
523                            } else {
524                                apply_result_path(input, &selected, result_path)
525                            };
526
527                            let output = if output_path == Some("null") {
528                                json!({})
529                            } else {
530                                apply_output_path(&after_result, output_path)
531                            };
532
533                            return Ok(output);
534                        }
535                        Err((error, cause)) => {
536                            add_event(
537                                shared_state,
538                                execution_arn,
539                                "TaskFailed",
540                                entered_event_id,
541                                json!({ "error": error, "cause": cause }),
542                            );
543
544                            if let Some(delay_ms) = should_retry(&retriers, &error, attempt, true) {
545                                attempt += 1;
546                                // Honour the ASL-computed backoff (already bounded
547                                // by MaxDelaySeconds inside `should_retry`).
548                                tokio::time::sleep(tokio::time::Duration::from_millis(delay_ms))
549                                    .await;
550                                continue;
551                            }
552
553                            return Err((error, cause));
554                        }
555                    }
556                }
557
558                add_event(
559                    shared_state,
560                    execution_arn,
561                    "TaskSucceeded",
562                    entered_event_id,
563                    json!({
564                        "resource": resource,
565                        "output": serde_json::to_string(&result).expect("serde_json::Value serialization is infallible"),
566                    }),
567                );
568
569                let selected = if let Some(selector) = state_def.get("ResultSelector") {
570                    apply_parameters(selector, &result, None)
571                } else {
572                    result
573                };
574
575                let after_result = if result_path == Some("null") {
576                    input.clone()
577                } else {
578                    apply_result_path(input, &selected, result_path)
579                };
580
581                let output = if output_path == Some("null") {
582                    json!({})
583                } else {
584                    apply_output_path(&after_result, output_path)
585                };
586
587                return Ok(output);
588            }
589            Err((error, cause)) => {
590                add_event(
591                    shared_state,
592                    execution_arn,
593                    "TaskFailed",
594                    entered_event_id,
595                    json!({ "error": error, "cause": cause }),
596                );
597
598                if let Some(delay_ms) = should_retry(&retriers, &error, attempt, true) {
599                    attempt += 1;
600                    // Honour the ASL-computed backoff (already bounded by
601                    // MaxDelaySeconds inside `should_retry`).
602                    tokio::time::sleep(tokio::time::Duration::from_millis(delay_ms)).await;
603                    continue;
604                }
605
606                return Err((error, cause));
607            }
608        }
609    }
610}
611
612/// Execute a Parallel state: run all branches concurrently, collect results into an array.
613async fn execute_parallel_state(
614    state_def: &Value,
615    input: &Value,
616    delivery: &Option<Arc<DeliveryBus>>,
617    dynamodb_state: &Option<SharedDynamoDbState>,
618    registry: &Option<SharedServiceRegistry>,
619    shared_state: &SharedStepFunctionsState,
620    execution_arn: &str,
621) -> Result<Value, (String, String)> {
622    let input_path = state_def["InputPath"].as_str();
623    let result_path = state_def["ResultPath"].as_str();
624    let output_path = state_def["OutputPath"].as_str();
625
626    let effective_input = if input_path == Some("null") {
627        json!({})
628    } else {
629        apply_input_path(input, input_path)
630    };
631
632    let branches = state_def["Branches"]
633        .as_array()
634        .cloned()
635        .unwrap_or_default();
636
637    if branches.is_empty() {
638        return Err((
639            "States.Runtime".to_string(),
640            "Parallel state has no Branches".to_string(),
641        ));
642    }
643
644    // Spawn all branches concurrently
645    let mut handles = Vec::new();
646    for branch_def in &branches {
647        let branch = branch_def.clone();
648        let branch_input = effective_input.clone();
649        let delivery = delivery.clone();
650        let ddb = dynamodb_state.clone();
651        let reg = registry.clone();
652        let state = shared_state.clone();
653        let arn = execution_arn.to_string();
654
655        handles.push(tokio::spawn(async move {
656            run_states(&branch, branch_input, &delivery, &ddb, &reg, &state, &arn).await
657        }));
658    }
659
660    // Collect results in order
661    let mut results = Vec::with_capacity(handles.len());
662    for handle in handles {
663        let result = handle.await.map_err(|e| {
664            (
665                "States.Runtime".to_string(),
666                format!("Branch execution panicked: {e}"),
667            )
668        })??;
669        results.push(result);
670    }
671
672    let branch_output = Value::Array(results);
673
674    // Apply ResultSelector if present
675    let selected = if let Some(selector) = state_def.get("ResultSelector") {
676        apply_parameters(selector, &branch_output, None)
677    } else {
678        branch_output
679    };
680
681    // Apply ResultPath
682    let after_result = if result_path == Some("null") {
683        input.clone()
684    } else {
685        apply_result_path(input, &selected, result_path)
686    };
687
688    // Apply OutputPath
689    let output = if output_path == Some("null") {
690        json!({})
691    } else {
692        apply_output_path(&after_result, output_path)
693    };
694
695    Ok(output)
696}
697
698/// Execute a Map state: iterate over an array and run a sub-state machine per item.
699/// Supports distributed mode: ItemReader (S3), ItemBatcher, ResultWriter (S3),
700/// ToleratedFailurePercentage, and MaxConcurrencyPath.
701#[allow(clippy::too_many_arguments)]
702async fn execute_map_state(
703    state_def: &Value,
704    input: &Value,
705    delivery: &Option<Arc<DeliveryBus>>,
706    dynamodb_state: &Option<SharedDynamoDbState>,
707    registry: &Option<SharedServiceRegistry>,
708    shared_state: &SharedStepFunctionsState,
709    execution_arn: &str,
710) -> Result<Value, (String, String)> {
711    let input_path = state_def["InputPath"].as_str();
712    let result_path = state_def["ResultPath"].as_str();
713    let output_path = state_def["OutputPath"].as_str();
714
715    let effective_input = if input_path == Some("null") {
716        json!({})
717    } else {
718        apply_input_path(input, input_path)
719    };
720
721    // Resolve MaxConcurrencyPath if present
722    let max_concurrency = if let Some(path) = state_def["MaxConcurrencyPath"].as_str() {
723        crate::io_processing::resolve_path(&effective_input, path)
724            .as_u64()
725            .unwrap_or(0)
726    } else {
727        state_def["MaxConcurrency"].as_u64().unwrap_or(0)
728    };
729    let effective_concurrency = if max_concurrency == 0 {
730        40
731    } else {
732        max_concurrency as usize
733    };
734
735    // Read items from ItemReader (S3) or ItemsPath
736    let items = if let Some(item_reader) = state_def.get("ItemReader") {
737        read_items_from_s3(item_reader, registry, execution_arn).await?
738    } else {
739        let items_path = state_def["ItemsPath"].as_str().unwrap_or("$");
740        let items_value = crate::io_processing::resolve_path(&effective_input, items_path);
741        items_value.as_array().cloned().unwrap_or_default()
742    };
743
744    // Apply ItemBatcher if present
745    let batch_config = state_def.get("ItemBatcher").cloned();
746    let batched_items = if let Some(ref batcher) = batch_config {
747        apply_item_batcher(&items, batcher, &effective_input)
748    } else {
749        items
750    };
751
752    // Get the iterator definition (ItemProcessor or Iterator for backwards compat)
753    let iterator_def = state_def
754        .get("ItemProcessor")
755        .or_else(|| state_def.get("Iterator"))
756        .cloned()
757        .ok_or_else(|| {
758            (
759                "States.Runtime".to_string(),
760                "Map state has no ItemProcessor or Iterator".to_string(),
761            )
762        })?;
763
764    let tolerated_failure_percentage = state_def["ToleratedFailurePercentage"]
765        .as_f64()
766        .unwrap_or(0.0);
767    let tolerated_failure_count = state_def["ToleratedFailureCount"].as_i64().unwrap_or(0);
768    let total_items = batched_items.len() as f64;
769    let mut failure_count = 0usize;
770
771    // A distributed Map (`ItemProcessor.ProcessorConfig.Mode == "DISTRIBUTED"`)
772    // materialises a MapRun record that `ListMapRuns` / `DescribeMapRun`
773    // surface. Inline Map states do not create one, matching AWS.
774    let is_distributed = iterator_def
775        .get("ProcessorConfig")
776        .and_then(|c| c.get("Mode"))
777        .and_then(Value::as_str)
778        == Some("DISTRIBUTED");
779    let map_run_arn = if is_distributed {
780        map_run_arn_for(execution_arn).inspect(|arn| {
781            start_map_run(
782                shared_state,
783                arn,
784                execution_arn,
785                // The configured MaxConcurrency (0 == unlimited), as AWS reports
786                // it, not the internal `effective_concurrency` (which defaults
787                // an unset value to 40).
788                max_concurrency as usize,
789                tolerated_failure_percentage,
790                tolerated_failure_count,
791                batched_items.len(),
792            );
793        })
794    } else {
795        None
796    };
797
798    let semaphore = Arc::new(tokio::sync::Semaphore::new(effective_concurrency));
799
800    // Process all items
801    let mut handles = Vec::new();
802    for (index, batch_item) in batched_items.into_iter().enumerate() {
803        let iter_def = iterator_def.clone();
804        let delivery = delivery.clone();
805        let ddb = dynamodb_state.clone();
806        let reg = registry.clone();
807        let state = shared_state.clone();
808        let arn = execution_arn.to_string();
809        let sem = semaphore.clone();
810
811        // Apply ItemSelector if present
812        let item_input = if let Some(selector) = state_def.get("ItemSelector") {
813            let mut ctx = serde_json::Map::new();
814            ctx.insert("value".to_string(), batch_item.clone());
815            ctx.insert("index".to_string(), json!(index));
816            apply_parameters(selector, &Value::Object(ctx), None)
817        } else {
818            batch_item
819        };
820
821        add_event(
822            shared_state,
823            execution_arn,
824            "MapIterationStarted",
825            0,
826            json!({ "index": index }),
827        );
828
829        handles.push(tokio::spawn(async move {
830            let _permit = sem
831                .acquire()
832                .await
833                .map_err(|_| ("States.Runtime".to_string(), "Semaphore closed".to_string()))?;
834            let result =
835                run_states(&iter_def, item_input, &delivery, &ddb, &reg, &state, &arn).await;
836            Ok::<(usize, Result<Value, (String, String)>), (String, String)>((index, result))
837        }));
838    }
839
840    // Collect results in order
841    let mut results: Vec<(usize, Value)> = Vec::with_capacity(handles.len());
842    for handle in handles {
843        let (index, result) = handle.await.map_err(|e| {
844            (
845                "States.Runtime".to_string(),
846                format!("Map iteration panicked: {e}"),
847            )
848        })??;
849
850        match result {
851            Ok(output) => {
852                add_event(
853                    shared_state,
854                    execution_arn,
855                    "MapIterationSucceeded",
856                    0,
857                    json!({ "index": index }),
858                );
859                results.push((index, output));
860            }
861            Err((error, cause)) => {
862                add_event(
863                    shared_state,
864                    execution_arn,
865                    "MapIterationFailed",
866                    0,
867                    json!({ "index": index, "error": error }),
868                );
869                failure_count += 1;
870                let failure_percentage = (failure_count as f64 / total_items) * 100.0;
871                if failure_percentage > tolerated_failure_percentage {
872                    if let Some(arn) = &map_run_arn {
873                        finish_map_run(
874                            shared_state,
875                            arn,
876                            execution_arn,
877                            results.len() as i64,
878                            failure_count as i64,
879                            "FAILED",
880                        );
881                    }
882                    return Err((error, cause));
883                }
884            }
885        }
886    }
887
888    // Every iteration is accounted for and the tolerated-failure threshold was
889    // not exceeded: the map run succeeded.
890    if let Some(arn) = &map_run_arn {
891        finish_map_run(
892            shared_state,
893            arn,
894            execution_arn,
895            results.len() as i64,
896            failure_count as i64,
897            "SUCCEEDED",
898        );
899    }
900
901    // Sort by index to maintain order
902    results.sort_by_key(|(i, _)| *i);
903    let map_output = Value::Array(results.into_iter().map(|(_, v)| v).collect());
904
905    // Write results to S3 if ResultWriter is configured
906    if let Some(result_writer) = state_def.get("ResultWriter") {
907        write_map_results_to_s3(result_writer, registry, execution_arn, &map_output).await?;
908    }
909
910    // Apply ResultSelector if present
911    let selected = if let Some(selector) = state_def.get("ResultSelector") {
912        apply_parameters(selector, &map_output, None)
913    } else {
914        map_output
915    };
916
917    // Apply ResultPath
918    let after_result = if result_path == Some("null") {
919        input.clone()
920    } else {
921        apply_result_path(input, &selected, result_path)
922    };
923
924    // Apply OutputPath
925    let output = if output_path == Some("null") {
926        json!({})
927    } else {
928        apply_output_path(&after_result, output_path)
929    };
930
931    Ok(output)
932}
933
934/// Build a MapRun ARN for a distributed Map running inside `execution_arn`.
935/// `arn:aws:states:R:A:execution:SM:Exec` -> `arn:aws:states:R:A:mapRun:SM/Exec:<label>`.
936fn map_run_arn_for(execution_arn: &str) -> Option<String> {
937    let (prefix, tail) = execution_arn.split_once(":execution:")?;
938    // `tail` is `StateMachineName:ExecutionName`; the MapRun ARN joins them with
939    // a slash and appends a unique label.
940    let sm_exec = tail.replacen(':', "/", 1);
941    let label = uuid::Uuid::new_v4().simple().to_string();
942    Some(format!("{prefix}:mapRun:{sm_exec}:{label}"))
943}
944
945/// Persist a fresh RUNNING MapRun record for a distributed Map.
946fn start_map_run(
947    shared_state: &SharedStepFunctionsState,
948    map_run_arn: &str,
949    execution_arn: &str,
950    max_concurrency: usize,
951    tolerated_failure_percentage: f64,
952    tolerated_failure_count: i64,
953    total: usize,
954) {
955    let account = account_id_from_arn(execution_arn).to_string();
956    let mut accounts = shared_state.write();
957    let s = accounts.get_or_create(&account);
958    s.map_runs.insert(
959        map_run_arn.to_string(),
960        MapRun {
961            map_run_arn: map_run_arn.to_string(),
962            execution_arn: execution_arn.to_string(),
963            max_concurrency: max_concurrency as i32,
964            tolerated_failure_percentage,
965            tolerated_failure_count,
966            status: "RUNNING".to_string(),
967            start_date: Utc::now(),
968            stop_date: None,
969            total_count: total as i64,
970            succeeded_count: 0,
971            failed_count: 0,
972        },
973    );
974}
975
976/// Transition a MapRun to a terminal status, recording the final iteration
977/// tallies. No-op if the record was reaped (e.g. a state reset mid-run).
978fn finish_map_run(
979    shared_state: &SharedStepFunctionsState,
980    map_run_arn: &str,
981    execution_arn: &str,
982    succeeded: i64,
983    failed: i64,
984    status: &str,
985) {
986    let account = account_id_from_arn(execution_arn).to_string();
987    let mut accounts = shared_state.write();
988    let s = accounts.get_or_create(&account);
989    if let Some(mr) = s.map_runs.get_mut(map_run_arn) {
990        mr.status = status.to_string();
991        mr.succeeded_count = succeeded;
992        mr.failed_count = failed;
993        mr.stop_date = Some(Utc::now());
994    }
995}
996
997/// Read items from S3 for distributed Map mode ItemReader.
998async fn read_items_from_s3(
999    item_reader: &Value,
1000    registry: &Option<SharedServiceRegistry>,
1001    execution_arn: &str,
1002) -> Result<Vec<Value>, (String, String)> {
1003    let resource = item_reader["Resource"]
1004        .as_str()
1005        .unwrap_or("arn:aws:states:::s3:getObject");
1006    if !resource.contains("s3:getObject") {
1007        return Err((
1008            "States.Runtime".to_string(),
1009            format!("ItemReader unsupported resource: {resource}"),
1010        ));
1011    }
1012
1013    let params = item_reader
1014        .get("Parameters")
1015        .cloned()
1016        .unwrap_or_else(|| json!({}));
1017    let bucket = params["Bucket"].as_str().ok_or_else(|| {
1018        (
1019            "States.Runtime".to_string(),
1020            "ItemReader missing Bucket".to_string(),
1021        )
1022    })?;
1023    let key = params["Key"].as_str().ok_or_else(|| {
1024        (
1025            "States.Runtime".to_string(),
1026            "ItemReader missing Key".to_string(),
1027        )
1028    })?;
1029
1030    let registry_arc = resolve_registry(registry)?;
1031    let account_id = account_from_execution_arn(execution_arn);
1032
1033    let body = call_sdk_action_raw_bytes(
1034        &registry_arc,
1035        "s3",
1036        "GetObject",
1037        &json!({ "Bucket": bucket, "Key": key }),
1038        &account_id,
1039    )
1040    .await?;
1041
1042    // Parse JSON array from the S3 object body
1043    let parsed: Value = serde_json::from_slice(&body).map_err(|e| {
1044        (
1045            "States.Runtime".to_string(),
1046            format!("ItemReader failed to parse S3 object as JSON: {e}"),
1047        )
1048    })?;
1049
1050    parsed.as_array().cloned().ok_or_else(|| {
1051        (
1052            "States.Runtime".to_string(),
1053            "ItemReader S3 object is not a JSON array".to_string(),
1054        )
1055    })
1056}
1057
1058/// Write Map results to S3 for distributed Map mode ResultWriter.
1059async fn write_map_results_to_s3(
1060    result_writer: &Value,
1061    registry: &Option<SharedServiceRegistry>,
1062    execution_arn: &str,
1063    results: &Value,
1064) -> Result<(), (String, String)> {
1065    let resource = result_writer["Resource"]
1066        .as_str()
1067        .unwrap_or("arn:aws:states:::s3:putObject");
1068    if !resource.contains("s3:putObject") {
1069        return Err((
1070            "States.Runtime".to_string(),
1071            format!("ResultWriter unsupported resource: {resource}"),
1072        ));
1073    }
1074
1075    let params = result_writer
1076        .get("Parameters")
1077        .cloned()
1078        .unwrap_or_else(|| json!({}));
1079    let bucket = params["Bucket"].as_str().ok_or_else(|| {
1080        (
1081            "States.Runtime".to_string(),
1082            "ResultWriter missing Bucket".to_string(),
1083        )
1084    })?;
1085    let prefix = params["Prefix"].as_str().unwrap_or("map-results/");
1086
1087    let registry_arc = resolve_registry(registry)?;
1088    let account_id = account_from_execution_arn(execution_arn);
1089
1090    use bytes::Bytes;
1091    let body = Bytes::from(
1092        serde_json::to_vec(results).expect("serde_json::Value serialization is infallible"),
1093    );
1094
1095    // Build a raw S3 PutObject request with the JSON body
1096    use fakecloud_core::service::AwsRequest;
1097    use http::{HeaderMap, Method};
1098    let service = registry_arc.get("s3").ok_or_else(|| {
1099        (
1100            "States.TaskFailed".to_string(),
1101            "S3 service not available for ResultWriter".to_string(),
1102        )
1103    })?;
1104
1105    let req = AwsRequest {
1106        service: "s3".to_string(),
1107        action: "PutObject".to_string(),
1108        region: "us-east-1".to_string(),
1109        account_id: account_id.to_string(),
1110        request_id: uuid::Uuid::new_v4().to_string(),
1111        headers: HeaderMap::new(),
1112        query_params: std::collections::HashMap::new(),
1113        body,
1114        body_stream: parking_lot::Mutex::new(None),
1115        path_segments: vec![bucket.to_string(), format!("{prefix}result.json")],
1116        raw_path: format!("/{bucket}/{prefix}result.json"),
1117        raw_query: String::new(),
1118        method: Method::PUT,
1119        is_query_protocol: false,
1120        access_key_id: None,
1121        principal: None,
1122    };
1123
1124    service.handle(req).await.map_err(|err| {
1125        let code = err.code().to_string();
1126        let msg = err.message();
1127        (
1128            format!("S3.{code}"),
1129            format!("ResultWriter PutObject failed: {msg}"),
1130        )
1131    })?;
1132
1133    Ok(())
1134}
1135
1136/// Apply ItemBatcher configuration to group items into batches.
1137fn apply_item_batcher(items: &[Value], batcher: &Value, _effective_input: &Value) -> Vec<Value> {
1138    let max_per_batch = batcher["MaxItemsPerBatch"].as_u64().unwrap_or(u64::MAX) as usize;
1139    let max_bytes = batcher["MaxInputBytesPerBatch"].as_u64().unwrap_or(0) as usize;
1140    let batch_input = batcher.get("BatchInput").cloned();
1141
1142    let mut batches: Vec<Vec<Value>> = Vec::new();
1143    let mut current_batch: Vec<Value> = Vec::new();
1144    let mut current_bytes = 0usize;
1145
1146    for item in items.iter().cloned() {
1147        let item_bytes = serde_json::to_vec(&item).unwrap_or_default().len();
1148        if !current_batch.is_empty()
1149            && (current_batch.len() >= max_per_batch
1150                || (max_bytes > 0 && current_bytes + item_bytes > max_bytes))
1151        {
1152            batches.push(current_batch);
1153            current_batch = Vec::new();
1154            current_bytes = 0;
1155        }
1156        current_bytes += item_bytes;
1157        current_batch.push(item);
1158    }
1159    if !current_batch.is_empty() {
1160        batches.push(current_batch);
1161    }
1162
1163    batches
1164        .into_iter()
1165        .enumerate()
1166        .map(|(index, batch)| {
1167            let mut map = serde_json::Map::new();
1168            map.insert("index".to_string(), json!(index));
1169            map.insert("items".to_string(), Value::Array(batch));
1170            if let Some(Value::Object(ref obj)) = batch_input {
1171                for (k, v) in obj {
1172                    map.insert(k.clone(), v.clone());
1173                }
1174            }
1175            Value::Object(map)
1176        })
1177        .collect()
1178}
1179
1180/// Invoke a resource (Lambda function or SDK integration).
1181#[allow(clippy::too_many_arguments)]
1182async fn invoke_resource(
1183    resource: &str,
1184    input: &Value,
1185    delivery: &Option<Arc<DeliveryBus>>,
1186    dynamodb_state: &Option<SharedDynamoDbState>,
1187    registry: &Option<SharedServiceRegistry>,
1188    execution_arn: &str,
1189    timeout_seconds: Option<u64>,
1190    heartbeat_seconds: Option<u64>,
1191    shared_state: &SharedStepFunctionsState,
1192) -> Result<Value, (String, String)> {
1193    // Direct activity ARN: arn:aws:states:<region>:<account>:activity:<name>
1194    if resource.contains(":states:") && resource.contains(":activity:") {
1195        return invoke_activity(
1196            resource,
1197            input,
1198            shared_state,
1199            timeout_seconds,
1200            heartbeat_seconds,
1201        )
1202        .await;
1203    }
1204
1205    // Direct Lambda ARN: arn:aws:lambda:<region>:<account>:function:<name>
1206    if resource.contains(":lambda:") && resource.contains(":function:") {
1207        return invoke_lambda_direct(resource, input, delivery, timeout_seconds).await;
1208    }
1209
1210    // SDK integration patterns: arn:aws:states:::<service>:<action>
1211    if resource.starts_with("arn:aws:states:::lambda:invoke") {
1212        let function_name = input["FunctionName"].as_str().unwrap_or("");
1213        let payload = if let Some(p) = input.get("Payload") {
1214            p.clone()
1215        } else {
1216            input.clone()
1217        };
1218        // The optimized `lambda:invoke` integration returns the AWS Invoke API
1219        // response envelope, not the bare function output — `ResultSelector`
1220        // expressions like `{"value.$": "$.Payload"}` depend on it. Wrap only
1221        // the successful result; error results (function errors and transport
1222        // failures) pass through unchanged, since a failed lambda:invoke task
1223        // has no result envelope on AWS. The direct-ARN path above
1224        // intentionally stays unwrapped — there real AWS returns the bare
1225        // payload. `SdkHttpMetadata`/`SdkResponseMetadata` are omitted; real
1226        // templates select `Payload`/`StatusCode`.
1227        return invoke_lambda_direct(function_name, &payload, delivery, timeout_seconds)
1228            .await
1229            .map(|payload| {
1230                json!({
1231                    "ExecutedVersion": "$LATEST",
1232                    "Payload": payload,
1233                    "StatusCode": 200,
1234                })
1235            });
1236    }
1237
1238    if resource.starts_with("arn:aws:states:::sqs:sendMessage") {
1239        return invoke_sqs_send_message(input, delivery);
1240    }
1241
1242    if resource.starts_with("arn:aws:states:::sns:publish") {
1243        return invoke_sns_publish(input, delivery);
1244    }
1245
1246    if resource.starts_with("arn:aws:states:::events:putEvents") {
1247        return invoke_eventbridge_put_events(input, delivery);
1248    }
1249
1250    if resource.starts_with("arn:aws:states:::dynamodb:getItem") {
1251        return invoke_dynamodb_get_item(input, dynamodb_state);
1252    }
1253
1254    if resource.starts_with("arn:aws:states:::dynamodb:putItem") {
1255        return invoke_dynamodb_put_item(input, dynamodb_state);
1256    }
1257
1258    if resource.starts_with("arn:aws:states:::dynamodb:deleteItem") {
1259        return invoke_dynamodb_delete_item(input, dynamodb_state);
1260    }
1261
1262    if resource.starts_with("arn:aws:states:::dynamodb:updateItem") {
1263        return invoke_dynamodb_update_item(input, dynamodb_state);
1264    }
1265
1266    // Nested Step Functions execution:
1267    //   arn:aws:states:::states:startExecution[.sync|.waitForTaskToken]
1268    // Routes through the same registry path as the generic aws-sdk
1269    // integration so the inner execution sees a real `StartExecution`
1270    // call. `.sync` polls `DescribeExecution` until the inner execution
1271    // reaches a terminal state, mirroring AWS' behavior of bubbling the
1272    // inner Output back to the parent.
1273    if let Some(tail) = resource.strip_prefix("arn:aws:states:::") {
1274        if tail.starts_with("states:startExecution") {
1275            let account_id = account_from_execution_arn(execution_arn);
1276            let result =
1277                invoke_aws_sdk_integration(tail, input, registry, &account_id, timeout_seconds)
1278                    .await;
1279            // Patch the inner execution's parent_execution_arn so the
1280            // `/execution-tree` introspection can walk back-references.
1281            // For `.sync`, the result is the DescribeExecution shape
1282            // (has `executionArn`); for fire-and-forget StartExecution the
1283            // initial StartExecution response also carries `executionArn`.
1284            if let Ok(ref value) = result {
1285                if let Some(inner_arn) = value
1286                    .get("executionArn")
1287                    .or_else(|| value.get("ExecutionArn"))
1288                    .and_then(Value::as_str)
1289                {
1290                    let mut accounts = shared_state.write();
1291                    if let Some(state) = accounts.get_mut(&account_id) {
1292                        if let Some(exec) = state.executions.get_mut(inner_arn) {
1293                            exec.parent_execution_arn = Some(execution_arn.to_string());
1294                        }
1295                    }
1296                }
1297            }
1298            return result;
1299        }
1300    }
1301
1302    // Generic AWS SDK integration: arn:aws:states:::aws-sdk:<service>:<action>[.<wait>]
1303    // Routes the call to the registered service via the central
1304    // ServiceRegistry, passing the Task's `Parameters` block as the
1305    // request body. Mirrors the AWS SDK service integration pattern in
1306    // real Step Functions.
1307    if let Some(rest) = resource.strip_prefix("arn:aws:states:::aws-sdk:") {
1308        let account_id = account_from_execution_arn(execution_arn);
1309        return invoke_aws_sdk_integration(rest, input, registry, &account_id, timeout_seconds)
1310            .await;
1311    }
1312
1313    // Optimized service integrations expose `.sync` variants for ECS,
1314    // Athena, and Glue. Route them through the same waiter machinery as
1315    // `aws-sdk:` so callers can write the AWS-blessed ARN forms.
1316    if let Some(tail) = resource.strip_prefix("arn:aws:states:::") {
1317        if tail.contains(".sync") {
1318            let account_id = account_from_execution_arn(execution_arn);
1319            return invoke_aws_sdk_integration(tail, input, registry, &account_id, timeout_seconds)
1320                .await;
1321        }
1322    }
1323
1324    Err((
1325        "States.TaskFailed".to_string(),
1326        format!("Unsupported resource: {resource}"),
1327    ))
1328}
1329
1330/// Convert an SDK integration action (camelCase, e.g. `getItem`) to its
1331/// PascalCase wire form (`GetItem`). Step Functions Task ARNs use
1332/// camelCase, but the underlying AWS service handlers expect PascalCase
1333/// `X-Amz-Target`-style action names.
1334fn camel_to_pascal(action: &str) -> String {
1335    let mut chars = action.chars();
1336    match chars.next() {
1337        None => String::new(),
1338        Some(first) => first.to_ascii_uppercase().to_string() + chars.as_str(),
1339    }
1340}
1341
1342/// Map a Step Functions SDK integration service id to the corresponding
1343/// fakecloud `service_name()`. Most match 1:1, but a handful of AWS SDK
1344/// service ids differ from the SigV4 service identifier we register
1345/// services under (e.g. `sfn` -> `states`, `cloudwatchlogs` -> `logs`).
1346fn map_sdk_service_id(service_id: &str) -> &str {
1347    match service_id {
1348        "sfn" => "states",
1349        "cloudwatchlogs" => "logs",
1350        // Default: pass through unchanged.
1351        other => other,
1352    }
1353}
1354
1355/// Extract the AWS account id from a Step Functions execution ARN
1356/// (`arn:aws:states:<region>:<account>:execution:...`). Falls back to
1357/// the AWS-conventional fixture id if the ARN is malformed.
1358fn account_from_execution_arn(execution_arn: &str) -> String {
1359    execution_arn
1360        .split(':')
1361        .nth(4)
1362        .filter(|s| !s.is_empty())
1363        .unwrap_or("123456789012")
1364        .to_string()
1365}
1366
1367/// Dispatch a Step Functions `aws-sdk:<service>:<action>` Task to the
1368/// registered service via the central [`fakecloud_core::registry::ServiceRegistry`].
1369/// `tail` is the trailing portion of the resource ARN after the
1370/// `aws-sdk:` prefix (e.g. `dynamodb:getItem` or `sqs:sendMessage.waitForTaskToken`).
1371async fn invoke_aws_sdk_integration(
1372    tail: &str,
1373    input: &Value,
1374    registry: &Option<SharedServiceRegistry>,
1375    account_id: &str,
1376    timeout_seconds: Option<u64>,
1377) -> Result<Value, (String, String)> {
1378    let registry_arc = resolve_registry(registry)?;
1379
1380    // Split `<service>:<action>[.<modifier>]`. The `.waitForTaskToken`
1381    // modifier is accepted but ignored — the integration runs synchronously
1382    // regardless. The `.sync` modifier triggers a polling loop after the
1383    // initial call to wait for the downstream operation to reach a terminal
1384    // state.
1385    let mut parts = tail.splitn(2, ':');
1386    let service_id = parts.next().filter(|s| !s.is_empty()).ok_or_else(|| {
1387        (
1388            "States.TaskFailed".to_string(),
1389            format!("Invalid aws-sdk Resource ARN: missing service in '{tail}'"),
1390        )
1391    })?;
1392    let action_with_mod = parts.next().filter(|s| !s.is_empty()).ok_or_else(|| {
1393        (
1394            "States.TaskFailed".to_string(),
1395            format!("Invalid aws-sdk Resource ARN: missing action in '{tail}'"),
1396        )
1397    })?;
1398    let action_camel = action_with_mod
1399        .split('.')
1400        .next()
1401        .filter(|s| !s.is_empty())
1402        .ok_or_else(|| {
1403            (
1404                "States.TaskFailed".to_string(),
1405                format!("Invalid aws-sdk Resource ARN: empty action in '{tail}'"),
1406            )
1407        })?;
1408    let modifiers: Vec<&str> = action_with_mod.split('.').skip(1).collect();
1409    let is_sync = modifiers.iter().any(|m| *m == "sync" || *m == "sync:2");
1410
1411    let action_pascal = camel_to_pascal(action_camel);
1412    let service_name = map_sdk_service_id(service_id).to_string();
1413
1414    // ECS's wire format is camelCase, but Step Functions's optimized
1415    // integration takes PascalCase parameter names in the state machine
1416    // definition. Translate top-level keys for the ECS service so AWS
1417    // Step Functions definitions Just Work against the fake.
1418    let translated_input = match service_name.as_str() {
1419        "ecs" => translate_ecs_keys_to_camel(input),
1420        _ => input.clone(),
1421    };
1422
1423    let initial = call_sdk_action(
1424        &registry_arc,
1425        &service_name,
1426        &action_pascal,
1427        &translated_input,
1428        account_id,
1429    )
1430    .await?;
1431
1432    if !is_sync {
1433        return Ok(initial);
1434    }
1435
1436    // `.sync` pattern: dispatch to the per-service waiter that polls until
1437    // the downstream operation reaches a terminal state. Returns the full
1438    // describe-shape result, or surfaces a terminal failure as
1439    // `States.TaskFailed`.
1440    sync_wait(
1441        &registry_arc,
1442        &service_name,
1443        &action_pascal,
1444        &initial,
1445        &translated_input,
1446        account_id,
1447        timeout_seconds,
1448    )
1449    .await
1450}
1451
1452/// Translate the top-level PascalCase keys that AWS Step Functions
1453/// state machines use for `ecs:runTask` Parameters into the camelCase
1454/// shape that the AWS ECS API (and our handler) expects. Unknown keys
1455/// pass through unchanged so callers can still send raw camelCase. The
1456/// translation is shallow on purpose — nested overrides keep their
1457/// existing camelCase shape on real AWS too.
1458fn translate_ecs_keys_to_camel(input: &Value) -> Value {
1459    let Some(obj) = input.as_object() else {
1460        return input.clone();
1461    };
1462    let mut out = serde_json::Map::with_capacity(obj.len());
1463    for (k, v) in obj.iter() {
1464        let camel = match k.as_str() {
1465            "Cluster" => "cluster",
1466            "TaskDefinition" => "taskDefinition",
1467            "LaunchType" => "launchType",
1468            "Group" => "group",
1469            "Overrides" => "overrides",
1470            "PlatformVersion" => "platformVersion",
1471            "NetworkConfiguration" => "networkConfiguration",
1472            "Tags" => "tags",
1473            "EnableExecuteCommand" => "enableExecuteCommand",
1474            "PropagateTags" => "propagateTags",
1475            "ReferenceId" => "referenceId",
1476            "StartedBy" => "startedBy",
1477            "Count" => "count",
1478            "CapacityProviderStrategy" => "capacityProviderStrategy",
1479            "PlacementConstraints" => "placementConstraints",
1480            "PlacementStrategy" => "placementStrategy",
1481            other => other,
1482        };
1483        out.insert(camel.to_string(), v.clone());
1484    }
1485    Value::Object(out)
1486}
1487
1488fn resolve_registry(
1489    registry: &Option<SharedServiceRegistry>,
1490) -> Result<Arc<fakecloud_core::registry::ServiceRegistry>, (String, String)> {
1491    let registry_handle = registry.as_ref().ok_or_else(|| {
1492        (
1493            "States.TaskFailed".to_string(),
1494            "No service registry configured for aws-sdk integration".to_string(),
1495        )
1496    })?;
1497    registry_handle.get().cloned().ok_or_else(|| {
1498        (
1499            "States.TaskFailed".to_string(),
1500            "Service registry not yet initialised; aws-sdk integration unavailable".to_string(),
1501        )
1502    })
1503}
1504
1505/// Call a single AWS SDK action against the registered service handler.
1506async fn call_sdk_action(
1507    registry: &Arc<fakecloud_core::registry::ServiceRegistry>,
1508    service_name: &str,
1509    action_pascal: &str,
1510    input: &Value,
1511    account_id: &str,
1512) -> Result<Value, (String, String)> {
1513    use bytes::Bytes;
1514    use fakecloud_core::service::AwsRequest;
1515    use http::{HeaderMap, Method};
1516
1517    let service = registry.get(service_name).ok_or_else(|| {
1518        (
1519            "States.TaskFailed".to_string(),
1520            format!("Unknown aws-sdk service '{service_name}'"),
1521        )
1522    })?;
1523
1524    let body_bytes = Bytes::from(
1525        serde_json::to_vec(input).expect("serde_json::Value serialization is infallible"),
1526    );
1527
1528    let req = AwsRequest {
1529        service: service_name.to_string(),
1530        action: action_pascal.to_string(),
1531        region: "us-east-1".to_string(),
1532        account_id: account_id.to_string(),
1533        request_id: uuid::Uuid::new_v4().to_string(),
1534        headers: HeaderMap::new(),
1535        query_params: std::collections::HashMap::new(),
1536        body: body_bytes,
1537        body_stream: parking_lot::Mutex::new(None),
1538        path_segments: vec![],
1539        raw_path: "/".to_string(),
1540        raw_query: String::new(),
1541        method: Method::POST,
1542        is_query_protocol: false,
1543        access_key_id: None,
1544        principal: None,
1545    };
1546
1547    let response = service.handle(req).await.map_err(|err| {
1548        let code = err.code().to_string();
1549        let msg = err.message();
1550        let prefix_service = match service_name {
1551            "dynamodb" => "DynamoDb".to_string(),
1552            "states" => "Sfn".to_string(),
1553            other => camel_to_pascal(other),
1554        };
1555        (
1556            format!("{prefix_service}.{code}"),
1557            format!("{action_pascal} failed: {msg}"),
1558        )
1559    })?;
1560
1561    let response_bytes = match response.body {
1562        fakecloud_core::service::ResponseBody::Bytes(b) => b,
1563        fakecloud_core::service::ResponseBody::File { .. } => {
1564            return Err((
1565                "States.TaskFailed".to_string(),
1566                "aws-sdk integration: file-backed response not supported".to_string(),
1567            ));
1568        }
1569    };
1570
1571    if response_bytes.is_empty() {
1572        return Ok(json!({}));
1573    }
1574    serde_json::from_slice(&response_bytes).map_err(|e| {
1575        (
1576            "States.TaskFailed".to_string(),
1577            format!("aws-sdk integration: failed to parse response JSON: {e}"),
1578        )
1579    })
1580}
1581
1582/// Call an SDK action and return raw bytes without JSON parsing.
1583/// Used by distributed Map mode to read/write S3 objects.
1584async fn call_sdk_action_raw_bytes(
1585    registry: &Arc<fakecloud_core::registry::ServiceRegistry>,
1586    service_name: &str,
1587    action_pascal: &str,
1588    input: &Value,
1589    account_id: &str,
1590) -> Result<bytes::Bytes, (String, String)> {
1591    use bytes::Bytes;
1592    use fakecloud_core::service::AwsRequest;
1593    use http::{HeaderMap, Method};
1594
1595    let service = registry.get(service_name).ok_or_else(|| {
1596        (
1597            "States.TaskFailed".to_string(),
1598            format!("Unknown aws-sdk service '{service_name}'"),
1599        )
1600    })?;
1601
1602    let body_bytes = Bytes::from(
1603        serde_json::to_vec(input).expect("serde_json::Value serialization is infallible"),
1604    );
1605
1606    let req = AwsRequest {
1607        service: service_name.to_string(),
1608        action: action_pascal.to_string(),
1609        region: "us-east-1".to_string(),
1610        account_id: account_id.to_string(),
1611        request_id: uuid::Uuid::new_v4().to_string(),
1612        headers: HeaderMap::new(),
1613        query_params: std::collections::HashMap::new(),
1614        body: body_bytes,
1615        body_stream: parking_lot::Mutex::new(None),
1616        path_segments: vec![],
1617        raw_path: "/".to_string(),
1618        raw_query: String::new(),
1619        method: Method::POST,
1620        is_query_protocol: false,
1621        access_key_id: None,
1622        principal: None,
1623    };
1624
1625    let response = service.handle(req).await.map_err(|err| {
1626        let code = err.code().to_string();
1627        let msg = err.message();
1628        let prefix_service = match service_name {
1629            "dynamodb" => "DynamoDb".to_string(),
1630            "states" => "Sfn".to_string(),
1631            other => camel_to_pascal(other),
1632        };
1633        (
1634            format!("{prefix_service}.{code}"),
1635            format!("{action_pascal} failed: {msg}"),
1636        )
1637    })?;
1638
1639    match response.body {
1640        fakecloud_core::service::ResponseBody::Bytes(b) => Ok(b),
1641        fakecloud_core::service::ResponseBody::File { .. } => Err((
1642            "States.TaskFailed".to_string(),
1643            "aws-sdk integration: file-backed response not supported".to_string(),
1644        )),
1645    }
1646}
1647
1648/// Cap on `.sync` polling so a stuck downstream task can't hang an
1649/// execution forever. Mirrors the `TimeoutSeconds` knob on the Task state
1650/// when one is set; otherwise defaults to 5 minutes which is enough for
1651/// the in-process services that ship today (Athena returns synchronously,
1652/// ECS tasks finish within seconds even when docker-less).
1653const SYNC_DEFAULT_TIMEOUT_SECS: u64 = 300;
1654const SYNC_POLL_INTERVAL_MS: u64 = 200;
1655
1656/// Dispatch `.sync` waiters by service+action. Each waiter polls the
1657/// matching describe-style API until the downstream operation reaches a
1658/// terminal state, then returns the full describe response.
1659async fn sync_wait(
1660    registry: &Arc<fakecloud_core::registry::ServiceRegistry>,
1661    service_name: &str,
1662    action_pascal: &str,
1663    initial: &Value,
1664    input: &Value,
1665    account_id: &str,
1666    timeout_seconds: Option<u64>,
1667) -> Result<Value, (String, String)> {
1668    match (service_name, action_pascal) {
1669        ("ecs", "RunTask") => {
1670            sync_wait_ecs_run_task(registry, initial, input, account_id, timeout_seconds).await
1671        }
1672        ("athena", "StartQueryExecution") => {
1673            sync_wait_athena_query(registry, initial, account_id, timeout_seconds).await
1674        }
1675        ("states", "StartExecution") => {
1676            sync_wait_states_start_execution(registry, initial, account_id, timeout_seconds).await
1677        }
1678        ("glue", "StartJobRun") => {
1679            // Poll the real Glue `GetJobRun` (the job run was created by the
1680            // StartJobRun integration that ran just before this waiter), so the
1681            // `.sync` result is the actual run — full JobRun shape, and a
1682            // FAILED/STOPPED/TIMEOUT state surfaces as a task failure rather
1683            // than a hardcoded SUCCEEDED.
1684            let job_run_id = initial
1685                .get("JobRunId")
1686                .and_then(Value::as_str)
1687                .unwrap_or_default()
1688                .to_string();
1689            let job_name = input
1690                .get("JobName")
1691                .and_then(Value::as_str)
1692                .unwrap_or_default()
1693                .to_string();
1694            let deadline = sync_deadline(timeout_seconds);
1695            loop {
1696                let described = call_sdk_action(
1697                    registry,
1698                    "glue",
1699                    "GetJobRun",
1700                    &json!({ "JobName": job_name, "RunId": job_run_id }),
1701                    account_id,
1702                )
1703                .await?;
1704                let state = described
1705                    .get("JobRun")
1706                    .and_then(|r| r.get("JobRunState"))
1707                    .and_then(Value::as_str)
1708                    .unwrap_or("");
1709                match state {
1710                    "SUCCEEDED" => return Ok(described),
1711                    "FAILED" | "STOPPED" | "TIMEOUT" | "ERROR" => {
1712                        return Err((
1713                            "States.TaskFailed".to_string(),
1714                            format!("Glue job run {job_run_id} ended in state {state}"),
1715                        ));
1716                    }
1717                    _ => {}
1718                }
1719                if std::time::Instant::now() >= deadline {
1720                    return Err((
1721                        "States.Timeout".to_string(),
1722                        format!(
1723                            "glue:startJobRun.sync timed out after {}s for run {job_run_id}",
1724                            sync_timeout_secs(timeout_seconds)
1725                        ),
1726                    ));
1727                }
1728                tokio::time::sleep(std::time::Duration::from_millis(SYNC_POLL_INTERVAL_MS)).await;
1729            }
1730        }
1731        _ => Err((
1732            "States.TaskFailed".to_string(),
1733            format!(
1734                "`.sync` is not supported for {service_name}:{action_pascal} yet — \
1735                 supported: ecs:RunTask, athena:StartQueryExecution, glue:StartJobRun, states:StartExecution"
1736            ),
1737        )),
1738    }
1739}
1740
1741async fn sync_wait_ecs_run_task(
1742    registry: &Arc<fakecloud_core::registry::ServiceRegistry>,
1743    initial: &Value,
1744    input: &Value,
1745    account_id: &str,
1746    timeout_seconds: Option<u64>,
1747) -> Result<Value, (String, String)> {
1748    let tasks = initial
1749        .get("tasks")
1750        .and_then(Value::as_array)
1751        .ok_or_else(|| {
1752            (
1753                "States.TaskFailed".to_string(),
1754                "ecs:RunTask.sync: response missing 'tasks' array".to_string(),
1755            )
1756        })?;
1757    if tasks.is_empty() {
1758        return Err((
1759            "States.TaskFailed".to_string(),
1760            "ecs:RunTask.sync: no tasks were started".to_string(),
1761        ));
1762    }
1763    let task_arns: Vec<String> = tasks
1764        .iter()
1765        .filter_map(|t| t.get("taskArn").and_then(Value::as_str).map(String::from))
1766        .collect();
1767    let cluster = input
1768        .get("cluster")
1769        .or_else(|| input.get("Cluster"))
1770        .and_then(Value::as_str)
1771        .map(String::from);
1772
1773    let deadline = sync_deadline(timeout_seconds);
1774    loop {
1775        let mut describe_input = json!({ "tasks": task_arns });
1776        if let Some(c) = &cluster {
1777            describe_input["cluster"] = json!(c);
1778        }
1779        let described = call_sdk_action(
1780            registry,
1781            "ecs",
1782            "DescribeTasks",
1783            &describe_input,
1784            account_id,
1785        )
1786        .await?;
1787        let described_tasks = described
1788            .get("tasks")
1789            .and_then(Value::as_array)
1790            .cloned()
1791            .unwrap_or_default();
1792        let all_stopped = !described_tasks.is_empty()
1793            && described_tasks
1794                .iter()
1795                .all(|t| t.get("lastStatus").and_then(Value::as_str) == Some("STOPPED"));
1796        if all_stopped {
1797            // Surface non-zero container exit codes or stop codes that map
1798            // to AWS-style failures. Per AWS docs, ECS RunTask.sync raises
1799            // States.TaskFailed when any container exits non-zero or the
1800            // task stops with a failure code.
1801            let any_failed = described_tasks.iter().any(|t| {
1802                let stop_code = t.get("stopCode").and_then(Value::as_str);
1803                let bad_stop = matches!(
1804                    stop_code,
1805                    Some(
1806                        "TaskFailedToStart"
1807                            | "EssentialContainerExited"
1808                            | "ServiceSchedulerInitiated"
1809                    )
1810                );
1811                let bad_exit = t
1812                    .get("containers")
1813                    .and_then(Value::as_array)
1814                    .map(|cs| {
1815                        cs.iter().any(|c| {
1816                            c.get("exitCode")
1817                                .and_then(Value::as_i64)
1818                                .map(|n| n != 0)
1819                                .unwrap_or(false)
1820                        })
1821                    })
1822                    .unwrap_or(false);
1823                bad_stop || bad_exit
1824            });
1825            if any_failed {
1826                let cause = described_tasks
1827                    .iter()
1828                    .find_map(|t| {
1829                        t.get("stoppedReason")
1830                            .and_then(Value::as_str)
1831                            .map(String::from)
1832                    })
1833                    .unwrap_or_else(|| "ECS task failed".to_string());
1834                return Err(("States.TaskFailed".to_string(), cause));
1835            }
1836            return Ok(described);
1837        }
1838        if std::time::Instant::now() >= deadline {
1839            return Err((
1840                "States.Timeout".to_string(),
1841                format!(
1842                    "ecs:RunTask.sync timed out after {}s waiting for {} task(s) to STOP",
1843                    sync_timeout_secs(timeout_seconds),
1844                    task_arns.len()
1845                ),
1846            ));
1847        }
1848        tokio::time::sleep(std::time::Duration::from_millis(SYNC_POLL_INTERVAL_MS)).await;
1849    }
1850}
1851
1852async fn sync_wait_athena_query(
1853    registry: &Arc<fakecloud_core::registry::ServiceRegistry>,
1854    initial: &Value,
1855    account_id: &str,
1856    timeout_seconds: Option<u64>,
1857) -> Result<Value, (String, String)> {
1858    let qid = initial
1859        .get("QueryExecutionId")
1860        .and_then(Value::as_str)
1861        .ok_or_else(|| {
1862            (
1863                "States.TaskFailed".to_string(),
1864                "athena:StartQueryExecution.sync: response missing QueryExecutionId".to_string(),
1865            )
1866        })?
1867        .to_string();
1868
1869    let deadline = sync_deadline(timeout_seconds);
1870    loop {
1871        let described = call_sdk_action(
1872            registry,
1873            "athena",
1874            "GetQueryExecution",
1875            &json!({ "QueryExecutionId": qid }),
1876            account_id,
1877        )
1878        .await?;
1879        let state = described
1880            .get("QueryExecution")
1881            .and_then(|qe| qe.get("Status"))
1882            .and_then(|s| s.get("State"))
1883            .and_then(Value::as_str)
1884            .unwrap_or("");
1885        match state {
1886            "SUCCEEDED" => return Ok(described),
1887            "FAILED" | "CANCELLED" => {
1888                let cause = described
1889                    .get("QueryExecution")
1890                    .and_then(|qe| qe.get("Status"))
1891                    .and_then(|s| s.get("StateChangeReason"))
1892                    .and_then(Value::as_str)
1893                    .unwrap_or("Athena query reached terminal failure state")
1894                    .to_string();
1895                return Err(("States.TaskFailed".to_string(), cause));
1896            }
1897            _ => {}
1898        }
1899        if std::time::Instant::now() >= deadline {
1900            return Err((
1901                "States.Timeout".to_string(),
1902                format!(
1903                    "athena:StartQueryExecution.sync timed out after {}s for query {qid}",
1904                    sync_timeout_secs(timeout_seconds)
1905                ),
1906            ));
1907        }
1908        tokio::time::sleep(std::time::Duration::from_millis(SYNC_POLL_INTERVAL_MS)).await;
1909    }
1910}
1911
1912/// Wait for a nested Step Functions execution to reach a terminal
1913/// state, then return the bubbled `Output` like AWS does for
1914/// `arn:aws:states:::states:startExecution.sync`. The initial
1915/// `StartExecution` response carries `executionArn`; we poll
1916/// `DescribeExecution` until status leaves `RUNNING`.
1917async fn sync_wait_states_start_execution(
1918    registry: &Arc<fakecloud_core::registry::ServiceRegistry>,
1919    initial: &Value,
1920    account_id: &str,
1921    timeout_seconds: Option<u64>,
1922) -> Result<Value, (String, String)> {
1923    let exec_arn = initial
1924        .get("executionArn")
1925        .or_else(|| initial.get("ExecutionArn"))
1926        .and_then(Value::as_str)
1927        .ok_or_else(|| {
1928            (
1929                "States.TaskFailed".to_string(),
1930                "states:startExecution.sync: response missing executionArn".to_string(),
1931            )
1932        })?
1933        .to_string();
1934
1935    let deadline = sync_deadline(timeout_seconds);
1936    loop {
1937        let described = call_sdk_action(
1938            registry,
1939            "states",
1940            "DescribeExecution",
1941            &json!({ "executionArn": exec_arn }),
1942            account_id,
1943        )
1944        .await?;
1945        let status = described
1946            .get("status")
1947            .or_else(|| described.get("Status"))
1948            .and_then(Value::as_str)
1949            .unwrap_or("");
1950        match status {
1951            "SUCCEEDED" => return Ok(described),
1952            "FAILED" | "TIMED_OUT" | "ABORTED" => {
1953                let cause = described
1954                    .get("cause")
1955                    .or_else(|| described.get("Cause"))
1956                    .and_then(Value::as_str)
1957                    .unwrap_or("Nested execution reached terminal failure state")
1958                    .to_string();
1959                return Err(("States.TaskFailed".to_string(), cause));
1960            }
1961            _ => {}
1962        }
1963        if std::time::Instant::now() >= deadline {
1964            return Err((
1965                "States.Timeout".to_string(),
1966                format!(
1967                    "states:startExecution.sync timed out after {}s for {exec_arn}",
1968                    sync_timeout_secs(timeout_seconds)
1969                ),
1970            ));
1971        }
1972        tokio::time::sleep(std::time::Duration::from_millis(SYNC_POLL_INTERVAL_MS)).await;
1973    }
1974}
1975
1976fn sync_timeout_secs(timeout_seconds: Option<u64>) -> u64 {
1977    timeout_seconds.unwrap_or(SYNC_DEFAULT_TIMEOUT_SECS)
1978}
1979
1980fn sync_deadline(timeout_seconds: Option<u64>) -> std::time::Instant {
1981    std::time::Instant::now() + std::time::Duration::from_secs(sync_timeout_secs(timeout_seconds))
1982}
1983
1984#[derive(Clone, Copy)]
1985pub(crate) enum UpdateClause {
1986    Set,
1987    Remove,
1988    Add,
1989    Delete,
1990}
1991
1992/// Invoke a Lambda function directly via DeliveryBus.
1993async fn invoke_lambda_direct(
1994    function_arn: &str,
1995    input: &Value,
1996    delivery: &Option<Arc<DeliveryBus>>,
1997    timeout_seconds: Option<u64>,
1998) -> Result<Value, (String, String)> {
1999    let delivery = delivery.as_ref().ok_or_else(|| {
2000        (
2001            "States.TaskFailed".to_string(),
2002            "No delivery bus configured for Lambda invocation".to_string(),
2003        )
2004    })?;
2005
2006    let payload =
2007        serde_json::to_string(input).expect("serde_json::Value serialization is infallible");
2008
2009    let invoke_future = delivery.invoke_lambda(function_arn, &payload);
2010
2011    let result = if let Some(timeout) = timeout_seconds {
2012        match tokio::time::timeout(tokio::time::Duration::from_secs(timeout), invoke_future).await {
2013            Ok(r) => r,
2014            Err(_) => {
2015                return Err((
2016                    "States.Timeout".to_string(),
2017                    format!("Task timed out after {timeout} seconds"),
2018                ));
2019            }
2020        }
2021    } else {
2022        invoke_future.await
2023    };
2024
2025    match result {
2026        Some(Ok(bytes)) => {
2027            let response_str = String::from_utf8_lossy(&bytes);
2028            let value: Value =
2029                serde_json::from_str(&response_str).unwrap_or(json!(response_str.to_string()));
2030
2031            // The Lambda runtime returns HTTP 200 even for unhandled function
2032            // errors, encoding the failure in the payload as
2033            // `{"errorMessage", "errorType", "stackTrace"}`. The delivery trait
2034            // only conveys bytes, so the payload is the only available signal —
2035            // the same heuristic the Lambda service uses for async destination
2036            // routing. Mirror real AWS: a function error fails the task with the
2037            // function's `errorType` as the Error and the serialized payload as
2038            // the Cause, which is exactly what makes Retry/Catch on custom
2039            // exception names work.
2040            //
2041            // Require BOTH `errorType` and `errorMessage` (not either) to avoid
2042            // misreading a legitimate success payload that merely happens to
2043            // contain one of those field names as a function error.
2044            if let Some(obj) = value.as_object() {
2045                if obj.contains_key("errorType") && obj.contains_key("errorMessage") {
2046                    let error_type = obj
2047                        .get("errorType")
2048                        .and_then(Value::as_str)
2049                        .unwrap_or("Exception")
2050                        .to_string();
2051                    let cause = serde_json::to_string(&value)
2052                        .expect("serde_json::Value serialization is infallible");
2053                    return Err((error_type, cause));
2054                }
2055            }
2056
2057            Ok(value)
2058        }
2059        Some(Err(e)) => {
2060            // Failures of the Lambda Invoke API call itself surface on real AWS
2061            // with `Lambda.`-prefixed error names (`Lambda.ServiceException`,
2062            // `Lambda.SdkClientException`, `Lambda.Unknown`, ...) — never
2063            // `States.TaskFailed` — and those are the names AWS documents for
2064            // Retry rules. The delivery error is an unstructured string with
2065            // the outcome flattened, so the honest default is `Lambda.Unknown`
2066            // ("outcome unknown"). The one case we can cheaply distinguish is a
2067            // missing function, which AWS reports as
2068            // `Lambda.ResourceNotFoundException`.
2069            if e.starts_with("Function not found") {
2070                Err(("Lambda.ResourceNotFoundException".to_string(), e))
2071            } else {
2072                Err(("Lambda.Unknown".to_string(), e))
2073            }
2074        }
2075        None => {
2076            // No runtime available — return empty result
2077            Ok(json!({}))
2078        }
2079    }
2080}
2081
2082/// Invoke an activity worker. Inserts a `PENDING` token into shared state
2083/// so a worker can claim it via `GetActivityTask`, then polls until the
2084/// worker calls `SendTaskSuccess` / `SendTaskFailure` or the heartbeat /
2085/// timeout windows expire.
2086async fn invoke_activity(
2087    activity_arn: &str,
2088    input: &Value,
2089    shared_state: &SharedStepFunctionsState,
2090    timeout_seconds: Option<u64>,
2091    heartbeat_seconds: Option<u64>,
2092) -> Result<Value, (String, String)> {
2093    use crate::state::TaskTokenState;
2094
2095    // Activity must exist (look up across accounts via ARN segment).
2096    let activity_account = activity_arn.split(':').nth(4).unwrap_or("").to_string();
2097    {
2098        let accounts = shared_state.read();
2099        let exists = accounts
2100            .get(&activity_account)
2101            .map(|s| s.activities.contains_key(activity_arn))
2102            .unwrap_or(false);
2103        if !exists {
2104            return Err((
2105                "States.TaskFailed".to_string(),
2106                format!("Activity does not exist: {activity_arn}"),
2107            ));
2108        }
2109    }
2110
2111    let token = format!(
2112        "FCToken-{}-{}",
2113        chrono::Utc::now().timestamp_nanos_opt().unwrap_or(0),
2114        uuid::Uuid::new_v4().simple(),
2115    );
2116    let now = chrono::Utc::now();
2117    let input_str =
2118        serde_json::to_string(input).expect("serde_json::Value serialization is infallible");
2119    {
2120        let mut accounts = shared_state.write();
2121        let state = accounts.get_or_create(&activity_account);
2122        state.task_tokens.insert(
2123            token.clone(),
2124            TaskTokenState {
2125                activity_arn: activity_arn.to_string(),
2126                status: "PENDING".to_string(),
2127                output: None,
2128                error: None,
2129                cause: None,
2130                input: Some(input_str),
2131                created_at: now,
2132                last_heartbeat_at: None,
2133                heartbeat_seconds: heartbeat_seconds.map(|s| s as i64),
2134                timeout_seconds: timeout_seconds.map(|s| s as i64),
2135            },
2136        );
2137    }
2138
2139    poll_task_token(
2140        shared_state,
2141        &activity_account,
2142        &token,
2143        timeout_seconds,
2144        heartbeat_seconds,
2145    )
2146    .await
2147}
2148
2149pub(crate) enum NextState {
2150    Name(String),
2151    End,
2152    Error(String),
2153}
2154
2155#[path = "interpreter_helpers.rs"]
2156mod interpreter_helpers;
2157pub(crate) use interpreter_helpers::*;
2158
2159#[cfg(test)]
2160#[path = "interpreter_tests.rs"]
2161mod tests;