Skip to main content

netget/client/dynamodb/
mod.rs

1//! DynamoDB client implementation
2pub mod actions;
3
4pub use actions::DynamoDbClientProtocol;
5
6use anyhow::{Context, Result};
7use aws_smithy_types::Blob;
8use std::net::SocketAddr;
9use std::sync::Arc;
10use tokio::sync::mpsc;
11use tracing::{error, info};
12
13use crate::client::dynamodb::actions::DYNAMODB_CLIENT_RESPONSE_RECEIVED_EVENT;
14use crate::client::llm_budget::call_llm_for_client;
15use crate::llm::actions::client_trait::{Client, ClientActionResult};
16use crate::llm::actions::protocol_trait::Protocol;
17use crate::llm::ollama_client::OllamaClient;
18use crate::llm::ClientLlmResult;
19use crate::protocol::{Event, StartupParams};
20use crate::state::app_state::AppState;
21use crate::state::client_handles::{ClientCommand, ClientSendOutcome};
22use crate::state::{AccessLogOwner, ClientId, ClientStatus};
23use crate::utils::truncate::truncate_for_log;
24
25/// Turn a `host:port` (or already-schemed) target into an endpoint URL.
26///
27/// Returns `None` for an empty address, which is the only case where falling through to the
28/// SDK's default AWS endpoint is what the caller asked for.
29fn endpoint_url_from_remote_addr(remote_addr: &str) -> Option<String> {
30    let trimmed = remote_addr.trim();
31    if trimmed.is_empty() {
32        return None;
33    }
34    if trimmed.starts_with("http://") || trimmed.starts_with("https://") {
35        Some(trimmed.to_string())
36    } else {
37        Some(format!("http://{trimmed}"))
38    }
39}
40
41/// DynamoDB client that interacts with AWS DynamoDB or local instances
42pub struct DynamoDbClient;
43
44impl DynamoDbClient {
45    /// Connect to a DynamoDB instance with integrated LLM actions
46    pub async fn connect_with_llm_actions(
47        remote_addr: String,
48        _llm_client: OllamaClient,
49        app_state: Arc<AppState>,
50        status_tx: mpsc::UnboundedSender<String>,
51        client_id: ClientId,
52        startup_params: Option<StartupParams>,
53    ) -> Result<SocketAddr> {
54        // All four parameters are declared `required: false`, so they must be read with
55        // `get_optional_string`. They were read with `get_string`, which *errors* when the
56        // key is absent — so passing any subset of them (the documented local-testing case
57        // is `endpoint_url` alone) failed the connect outright with "Required string
58        // parameter 'region' is missing". Only passing none of them, or all four, worked.
59        let region = startup_params
60            .as_ref()
61            .map(|p| p.get_optional_string("region"))
62            .transpose()?
63            .flatten()
64            .unwrap_or_else(|| "us-east-1".to_string());
65        // The SDK builds `https://dynamodb.<region>.amazonaws.com` from this; see `aws_support`.
66        crate::client::aws_support::validate_region("DynamoDB", &region)?;
67
68        let endpoint_url = startup_params
69            .as_ref()
70            .map(|p| p.get_optional_string("endpoint_url"))
71            .transpose()?
72            .flatten();
73
74        let access_key_id = startup_params
75            .as_ref()
76            .map(|p| p.get_optional_string("access_key_id"))
77            .transpose()?
78            .flatten();
79
80        let secret_access_key = startup_params
81            .as_ref()
82            .map(|p| p.get_optional_string("secret_access_key"))
83            .transpose()?
84            .flatten();
85
86        // `remote_addr` is what the operator or the model actually typed as the target, and
87        // it used to be dropped on the floor (`_remote_addr`): without an explicit
88        // `endpoint_url` the SDK's default resolver produces
89        // `https://dynamodb.<region>.amazonaws.com` and the default credential chain signs
90        // with whatever ambient credentials the machine has — so a client aimed at
91        // `localhost:8000` issued real reads and writes against real AWS. An address here
92        // now means what it says. `endpoint_url` still wins when both are given, and
93        // targeting AWS proper is done by passing that URL explicitly.
94        let endpoint_url = endpoint_url.or_else(|| endpoint_url_from_remote_addr(&remote_addr));
95
96        // A custom endpoint and no credentials would sign with the operator's ambient AWS
97        // identity against a host the model may have chosen; see `aws_support`.
98        crate::client::aws_support::refuse_ambient_credentials(
99            "DynamoDB",
100            endpoint_url.as_deref(),
101            access_key_id.is_some() && secret_access_key.is_some(),
102        )?;
103
104        info!(
105            "DynamoDB client {} initializing for region {}",
106            client_id, region
107        );
108
109        // Store configuration in protocol_data
110        app_state
111            .with_client_mut(client_id, |client| {
112                client.set_protocol_field("region".to_string(), serde_json::json!(region.clone()));
113                if let Some(endpoint) = &endpoint_url {
114                    client.set_protocol_field(
115                        "endpoint_url".to_string(),
116                        serde_json::json!(endpoint),
117                    );
118                }
119                if let Some(key_id) = &access_key_id {
120                    client
121                        .set_protocol_field("access_key_id".to_string(), serde_json::json!(key_id));
122                }
123                if let Some(secret_key) = &secret_access_key {
124                    client.set_protocol_field(
125                        "secret_access_key".to_string(),
126                        serde_json::json!(secret_key),
127                    );
128                }
129            })
130            .await;
131
132        // Update status
133        app_state
134            .update_client_status(client_id, ClientStatus::Connected)
135            .await;
136        let _ = status_tx.send(format!(
137            "[CLIENT] DynamoDB client {} ready for region {}{}",
138            client_id,
139            region,
140            endpoint_url
141                .as_ref()
142                .map(|e| format!(" (endpoint: {})", e))
143                .unwrap_or_default()
144        ));
145        let _ = status_tx.send("__UPDATE_UI__".to_string());
146
147        // Command channel for injected actions (the dashboard's [ send ] row).
148        // This client raises no connected event, so there is no LLM call to register
149        // ahead of - but the channel still goes up before `connect` returns, which is
150        // what the dashboard waits on.
151        //
152        // The command loop replaces the 5s "has the client been removed yet" poll this
153        // task used to run: `remove_client` drops the handle, the sender goes with it,
154        // and `recv()` returns None - promptly, and without a timer.
155        let command_rx =
156            crate::client::command_support::register_command_channel(&app_state, client_id).await;
157        let task_registrar = app_state.clone();
158        let conn_llm = _llm_client.clone();
159        let task_handle = tokio::spawn(Self::command_loop(
160            command_rx,
161            client_id,
162            app_state.clone(),
163            _llm_client.clone(),
164            status_tx.clone(),
165        ));
166        task_registrar
167            .register_client_task(client_id, task_handle)
168            .await;
169
170        // Raise the connected event.
171        //
172        // DYNAMODB_CLIENT_CONNECTED_EVENT was declared and nothing raised it, and this
173        // client never called the LLM at all -- so the model was never consulted when a
174        // DynamoDB client came up, and the injected-command path was the only way an
175        // action could reach the wire.
176        //
177        // From a registered task, not inline: a dashboard-created client defaults to a
178        // `*` -> manual rule and awaiting a parked answer would block creation. Operations
179        // go through run_operation_once, which raises no event.
180        let conn_state = app_state.clone();
181        let conn_status = status_tx.clone();
182        let conn_region = region.clone();
183        let conn_endpoint = endpoint_url.clone();
184        let conn_task = tokio::spawn(async move {
185            let Some(instruction) = conn_state.get_instruction_for_client(client_id).await else {
186                return;
187            };
188            let protocol = crate::client::dynamodb::actions::DynamoDbClientProtocol::new();
189            // The event type declares `region` (required) and `endpoint`; it was raised with
190            // an empty object, so the model was documented into reading two fields that were
191            // never there.
192            let event = Event::new(
193                &crate::client::dynamodb::actions::DYNAMODB_CLIENT_CONNECTED_EVENT,
194                serde_json::json!({
195                    "region": conn_region,
196                    "endpoint": conn_endpoint,
197                }),
198            );
199            match crate::client::llm_budget::call_llm_for_client(
200                &conn_llm,
201                &conn_state,
202                client_id.to_string(),
203                &instruction,
204                "",
205                Some(&event),
206                &protocol,
207                &conn_status,
208            )
209            .await
210            {
211                Ok(result) => {
212                    if let Some(mem) = result.memory_updates {
213                        conn_state.set_memory_for_client(client_id, mem).await;
214                    }
215                    use crate::llm::actions::client_trait::{Client, ClientActionResult};
216                    for action in result.actions {
217                        // Every arm is accounted for. This was a `let ... else { continue }`,
218                        // which swallowed `Disconnect` — the answer this protocol's own
219                        // static-mode startup example prescribes — along with every executor
220                        // error, with no log line at all: the model was asked, answered, and
221                        // was ignored in silence.
222                        match protocol.execute_action(action.clone()) {
223                            Ok(ClientActionResult::Custom { name, data }) => {
224                                if let Err(e) =
225                                    Self::run_operation_once(client_id, &name, &data, &conn_state)
226                                        .await
227                                {
228                                    error!(
229                                        "DynamoDB client {} connect-time {} failed: {}",
230                                        client_id, name, e
231                                    );
232                                }
233                            }
234                            Ok(other) => info!(
235                                "DynamoDB client {} connect-time action {:?} needs no DynamoDB \
236                                 operation",
237                                client_id, other
238                            ),
239                            Err(e) => error!(
240                                "DynamoDB client {} could not execute connect-time action {}: {}",
241                                client_id, action, e
242                            ),
243                        }
244                    }
245                }
246                Err(e) => error!(
247                    "DynamoDB client {} LLM error on connected event: {}",
248                    client_id, e
249                ),
250            }
251        });
252        task_registrar
253            .register_client_task(client_id, conn_task)
254            .await;
255
256        // Return a dummy local address (DynamoDB is HTTP-based)
257        Ok("0.0.0.0:0".parse().unwrap())
258    }
259
260    /// Execute one DynamoDB operation by the name `execute_action` produced, and
261    /// raise the `dynamodb_response_received` event with its outcome.
262    ///
263    /// This is the single dispatch point: the command loop and any future LLM path
264    /// both come through here, so the six advertised verbs cannot drift apart from
265    /// what is actually implemented.
266    pub async fn execute_operation(
267        client_id: ClientId,
268        operation: String,
269        data: serde_json::Value,
270        app_state: Arc<AppState>,
271        llm_client: OllamaClient,
272        status_tx: mpsc::UnboundedSender<String>,
273    ) -> Result<serde_json::Value> {
274        Self::execute_operation_inner(
275            client_id, operation, data, app_state, llm_client, status_tx, true,
276        )
277        .await
278    }
279
280    /// `notify == false` runs the operation without raising
281    /// `dynamodb_response_received`.
282    ///
283    /// Two reasons, and both matter. It bounds the loop: a follow-up action the model
284    /// returned for a response event must not raise another response event, or one
285    /// operation could drive the model round and round. And it breaks a genuine type
286    /// cycle -- notify -> apply_action -> execute_operation -> notify is a recursive
287    /// async chain rustc cannot prove `Send`, so `tokio::spawn` rejects it.
288    async fn execute_operation_inner(
289        client_id: ClientId,
290        operation: String,
291        data: serde_json::Value,
292        app_state: Arc<AppState>,
293        llm_client: OllamaClient,
294        status_tx: mpsc::UnboundedSender<String>,
295        notify: bool,
296    ) -> Result<serde_json::Value> {
297        info!(
298            "DynamoDB client {} executing operation: {}",
299            client_id, operation
300        );
301
302        let (region, endpoint_url, access_key_id, secret_access_key) =
303            Self::get_config(&app_state, client_id).await?;
304
305        let config = Self::build_aws_config(
306            &region,
307            endpoint_url.as_deref(),
308            access_key_id.as_deref(),
309            secret_access_key.as_deref(),
310        )
311        .await?;
312
313        let ddb = aws_sdk_dynamodb::Client::new(&config);
314
315        let result = match operation.as_str() {
316            "put_item" => Self::put_item(&ddb, &data).await,
317            "get_item" => Self::get_item(&ddb, &data).await,
318            "query" => Self::query(&ddb, &data).await,
319            "scan" => Self::scan(&ddb, &data).await,
320            "update_item" => Self::update_item(&ddb, &data).await,
321            "delete_item" => Self::delete_item(&ddb, &data).await,
322            other => Err(anyhow::anyhow!("Unknown DynamoDB operation: {}", other)),
323        };
324
325        let (success, data, error_text) = match &result {
326            Ok(value) => {
327                info!("DynamoDB client {} {} succeeded", client_id, operation);
328                (true, Some(value.clone()), None)
329            }
330            Err(e) => {
331                error!("DynamoDB client {} {} failed: {}", client_id, operation, e);
332                (false, None, Some(e.to_string()))
333            }
334        };
335
336        // Raise the response event from its own registered task rather than inline. A
337        // dashboard-created client defaults to a `*` -> manual routing rule, so this LLM
338        // call can park for minutes waiting for a human; awaiting it here would wedge the
339        // command loop and make an injected action that in fact succeeded look to the
340        // dashboard like a timeout.
341        if !notify {
342            return result;
343        }
344
345        let notify_state = app_state.clone();
346        let notify = tokio::spawn(async move {
347            if let Err(e) = Self::call_llm_with_response(
348                client_id,
349                &operation,
350                success,
351                data,
352                error_text,
353                &app_state,
354                &llm_client,
355                &status_tx,
356            )
357            .await
358            {
359                error!(
360                    "DynamoDB client {} response notification failed: {}",
361                    client_id, e
362                );
363            }
364        });
365        notify_state.register_client_task(client_id, notify).await;
366
367        result
368    }
369
370    /// Build a client and run one operation. Raises no event, calls no LLM.
371    ///
372    /// Deliberately free of any path back into `call_llm_with_response`. That would make
373    /// the async type self-referential -- notify -> apply -> operation -> notify -- which
374    /// rustc cannot prove `Send`, so `tokio::spawn` refuses it outright. Follow-up actions
375    /// the model returns for a response event run through here, which both breaks that
376    /// cycle and bounds the loop: a follow-up cannot raise another response event.
377    async fn run_operation_once(
378        client_id: ClientId,
379        operation: &str,
380        data: &serde_json::Value,
381        app_state: &Arc<AppState>,
382    ) -> Result<serde_json::Value> {
383        let (region, endpoint_url, access_key_id, secret_access_key) =
384            Self::get_config(app_state, client_id).await?;
385
386        let config = Self::build_aws_config(
387            &region,
388            endpoint_url.as_deref(),
389            access_key_id.as_deref(),
390            secret_access_key.as_deref(),
391        )
392        .await?;
393
394        let ddb = aws_sdk_dynamodb::Client::new(&config);
395
396        match operation {
397            "put_item" => Self::put_item(&ddb, data).await,
398            "get_item" => Self::get_item(&ddb, data).await,
399            "query" => Self::query(&ddb, data).await,
400            "scan" => Self::scan(&ddb, data).await,
401            "update_item" => Self::update_item(&ddb, data).await,
402            "delete_item" => Self::delete_item(&ddb, data).await,
403            other => Err(anyhow::anyhow!("Unknown DynamoDB operation: {}", other)),
404        }
405    }
406
407    /// Read an `{":v": {"S": "x"}}` map from action data into AttributeValues.
408    fn attribute_values_from(
409        data: &serde_json::Value,
410        field: &str,
411    ) -> Result<Option<std::collections::HashMap<String, aws_sdk_dynamodb::types::AttributeValue>>>
412    {
413        let Some(map) = data.get(field).and_then(|v| v.as_object()) else {
414            return Ok(None);
415        };
416        let mut out = std::collections::HashMap::new();
417        for (key, value) in map {
418            out.insert(key.clone(), Self::json_to_attribute_value(key, value)?);
419        }
420        Ok(Some(out))
421    }
422
423    /// Read a `{"id": {"S": "x"}}` key/item map from action data.
424    fn attribute_map_from(
425        data: &serde_json::Value,
426        field: &str,
427    ) -> Result<std::collections::HashMap<String, aws_sdk_dynamodb::types::AttributeValue>> {
428        let map = data
429            .get(field)
430            .and_then(|v| v.as_object())
431            .with_context(|| format!("Missing '{}' in DynamoDB action data", field))?;
432        let mut out = std::collections::HashMap::new();
433        for (key, value) in map {
434            out.insert(key.clone(), Self::json_to_attribute_value(key, value)?);
435        }
436        Ok(out)
437    }
438
439    fn table_name_from(data: &serde_json::Value) -> Result<String> {
440        Ok(data
441            .get("table_name")
442            .and_then(|v| v.as_str())
443            .context("Missing 'table_name' in DynamoDB action data")?
444            .to_string())
445    }
446
447    /// PutItem
448    async fn put_item(
449        ddb: &aws_sdk_dynamodb::Client,
450        data: &serde_json::Value,
451    ) -> Result<serde_json::Value> {
452        let table_name = Self::table_name_from(data)?;
453        let item = Self::attribute_map_from(data, "item")?;
454
455        ddb.put_item()
456            .table_name(&table_name)
457            .set_item(Some(item))
458            .send()
459            .await
460            .context("PutItem failed")?;
461
462        Ok(serde_json::json!({ "table_name": table_name, "written": true }))
463    }
464
465    /// GetItem
466    async fn get_item(
467        ddb: &aws_sdk_dynamodb::Client,
468        data: &serde_json::Value,
469    ) -> Result<serde_json::Value> {
470        let table_name = Self::table_name_from(data)?;
471        let key = Self::attribute_map_from(data, "key")?;
472
473        let output = ddb
474            .get_item()
475            .table_name(&table_name)
476            .set_key(Some(key))
477            .send()
478            .await
479            .context("GetItem failed")?;
480
481        let item_json = match output.item {
482            Some(item) => Self::attribute_map_to_json(&item),
483            None => serde_json::json!(null),
484        };
485
486        Ok(serde_json::json!({ "table_name": table_name, "item": item_json }))
487    }
488
489    /// Query
490    async fn query(
491        ddb: &aws_sdk_dynamodb::Client,
492        data: &serde_json::Value,
493    ) -> Result<serde_json::Value> {
494        let table_name = Self::table_name_from(data)?;
495        let key_condition_expression = data
496            .get("key_condition_expression")
497            .and_then(|v| v.as_str())
498            .context("Missing 'key_condition_expression' in DynamoDB action data")?;
499
500        let output = ddb
501            .query()
502            .table_name(&table_name)
503            .key_condition_expression(key_condition_expression)
504            .set_expression_attribute_values(Self::attribute_values_from(
505                data,
506                "expression_attribute_values",
507            )?)
508            .send()
509            .await
510            .context("Query failed")?;
511
512        let items: Vec<serde_json::Value> = output
513            .items()
514            .iter()
515            .map(Self::attribute_map_to_json)
516            .collect();
517
518        Ok(serde_json::json!({
519            "table_name": table_name,
520            "count": items.len(),
521            "items": items,
522        }))
523    }
524
525    /// Scan
526    async fn scan(
527        ddb: &aws_sdk_dynamodb::Client,
528        data: &serde_json::Value,
529    ) -> Result<serde_json::Value> {
530        let table_name = Self::table_name_from(data)?;
531
532        let mut request = ddb
533            .scan()
534            .table_name(&table_name)
535            .set_expression_attribute_values(Self::attribute_values_from(
536                data,
537                "expression_attribute_values",
538            )?);
539
540        if let Some(filter) = data.get("filter_expression").and_then(|v| v.as_str()) {
541            request = request.filter_expression(filter);
542        }
543
544        let output = request.send().await.context("Scan failed")?;
545
546        let items: Vec<serde_json::Value> = output
547            .items()
548            .iter()
549            .map(Self::attribute_map_to_json)
550            .collect();
551
552        Ok(serde_json::json!({
553            "table_name": table_name,
554            "count": items.len(),
555            "items": items,
556        }))
557    }
558
559    /// UpdateItem
560    async fn update_item(
561        ddb: &aws_sdk_dynamodb::Client,
562        data: &serde_json::Value,
563    ) -> Result<serde_json::Value> {
564        let table_name = Self::table_name_from(data)?;
565        let key = Self::attribute_map_from(data, "key")?;
566        let update_expression = data
567            .get("update_expression")
568            .and_then(|v| v.as_str())
569            .context("Missing 'update_expression' in DynamoDB action data")?;
570
571        let output = ddb
572            .update_item()
573            .table_name(&table_name)
574            .set_key(Some(key))
575            .update_expression(update_expression)
576            .set_expression_attribute_values(Self::attribute_values_from(
577                data,
578                "expression_attribute_values",
579            )?)
580            .return_values(aws_sdk_dynamodb::types::ReturnValue::AllNew)
581            .send()
582            .await
583            .context("UpdateItem failed")?;
584
585        let attributes = match output.attributes {
586            Some(attrs) => Self::attribute_map_to_json(&attrs),
587            None => serde_json::json!(null),
588        };
589
590        Ok(serde_json::json!({
591            "table_name": table_name,
592            "updated": true,
593            "attributes": attributes,
594        }))
595    }
596
597    /// DeleteItem
598    async fn delete_item(
599        ddb: &aws_sdk_dynamodb::Client,
600        data: &serde_json::Value,
601    ) -> Result<serde_json::Value> {
602        let table_name = Self::table_name_from(data)?;
603        let key = Self::attribute_map_from(data, "key")?;
604
605        ddb.delete_item()
606            .table_name(&table_name)
607            .set_key(Some(key))
608            .send()
609            .await
610            .context("DeleteItem failed")?;
611
612        Ok(serde_json::json!({ "table_name": table_name, "deleted": true }))
613    }
614
615    /// Apply one executed action against DynamoDB. Shared by every path that can
616    /// produce an action, so an injected one behaves identically to an LLM one.
617    ///
618    /// The AWS SDK owns the socket and reports no wire byte count, so a completed
619    /// operation is `Executed { detail }` naming the operation and its result -
620    /// never `Sent`, which would be a fabricated byte count.
621    async fn apply_action(
622        result: ClientActionResult,
623        client_id: ClientId,
624        app_state: &Arc<AppState>,
625        llm_client: &OllamaClient,
626        status_tx: &mpsc::UnboundedSender<String>,
627    ) -> ClientSendOutcome {
628        match result {
629            ClientActionResult::Custom { name, data } => {
630                match Self::execute_operation(
631                    client_id,
632                    name.clone(),
633                    data,
634                    app_state.clone(),
635                    llm_client.clone(),
636                    status_tx.clone(),
637                )
638                .await
639                {
640                    Ok(value) => ClientSendOutcome::Executed {
641                        detail: format!(
642                            "{} completed: {}",
643                            name,
644                            truncate_for_log(&value.to_string(), 200)
645                        ),
646                    },
647                    Err(e) => {
648                        let _ = status_tx
649                            .send(format!("[ERROR] DynamoDB operation {} failed: {}", name, e));
650                        // A failed operation is `Rejected`, not `Executed`. Both used to be
651                        // `Executed`, so in the dashboard a DynamoDB rejection, an
652                        // unconvertible attribute and a completed write rendered identically
653                        // and the difference sat in free text. `Rejected` is documented as
654                        // "unknown type, bad params", which is exactly what a conversion
655                        // failure is; for a service error it is at least honestly distinct
656                        // from success.
657                        ClientSendOutcome::Rejected {
658                            error: format!(
659                                "{} failed: {}",
660                                name,
661                                truncate_for_log(&e.to_string(), 200)
662                            ),
663                        }
664                    }
665                }
666            }
667            ClientActionResult::Disconnect => ClientSendOutcome::Disconnected,
668            ClientActionResult::WaitForMore => ClientSendOutcome::Executed {
669                detail: "wait_for_more".to_string(),
670            },
671            ClientActionResult::NoAction => ClientSendOutcome::Executed {
672                detail: "no_action".to_string(),
673            },
674            ClientActionResult::SendData(_) => ClientSendOutcome::Executed {
675                detail: "send_data has no meaning for the DynamoDB client: it speaks the \
676                         DynamoDB API through the AWS SDK, not a socket this client owns"
677                    .to_string(),
678            },
679            ClientActionResult::Multiple(_) => ClientSendOutcome::Executed {
680                detail: "the DynamoDB client's own verbs never produce Multiple; nothing was \
681                         applied"
682                    .to_string(),
683            },
684        }
685    }
686
687    /// Drain injected commands until the channel closes (the client was removed) or
688    /// an injected `disconnect` ends the session.
689    ///
690    /// `command_support::handle_stream_client_command` cannot serve this client:
691    /// every DynamoDB verb yields `ClientActionResult::Custom` and there is no write
692    /// half to put bytes on. Actions therefore go through [`Self::apply_action`], and
693    /// the outcome is logged and replied exactly the way the generic arm does it.
694    async fn command_loop(
695        mut command_rx: mpsc::Receiver<ClientCommand>,
696        client_id: ClientId,
697        app_state: Arc<AppState>,
698        llm_client: OllamaClient,
699        status_tx: mpsc::UnboundedSender<String>,
700    ) {
701        let protocol = crate::client::dynamodb::actions::DynamoDbClientProtocol::new();
702
703        while let Some(command) = command_rx.recv().await {
704            let action = command.action.clone();
705            let outcome = match protocol.execute_action(action.clone()) {
706                Err(e) => ClientSendOutcome::Rejected {
707                    error: e.to_string(),
708                },
709                Ok(result) => {
710                    Self::apply_action(result, client_id, &app_state, &llm_client, &status_tx).await
711                }
712            };
713            let disconnected = matches!(outcome, ClientSendOutcome::Disconnected);
714
715            app_state
716                .record_access_log(
717                    AccessLogOwner::Client(client_id.as_u32()),
718                    protocol.protocol_name(),
719                    None,
720                    "injected_action",
721                    action,
722                    vec![serde_json::to_value(&outcome).unwrap_or(serde_json::Value::Null)],
723                )
724                .await;
725            let _ = status_tx.send("__UPDATE_UI__".to_string());
726            crate::client::command_support::reply(command, Ok(outcome));
727
728            if disconnected {
729                info!(
730                    "DynamoDB client {} disconnecting on injected action",
731                    client_id
732                );
733                app_state
734                    .update_client_status(client_id, ClientStatus::Disconnected)
735                    .await;
736                break;
737            }
738        }
739
740        info!("DynamoDB client {} command loop stopped", client_id);
741        // Never leave the dashboard offering [ send ] into a client that is gone.
742        app_state.remove_client_handle(client_id).await;
743        let _ = status_tx.send("__UPDATE_UI__".to_string());
744    }
745
746    /// Get DynamoDB configuration from client state
747    async fn get_config(
748        app_state: &AppState,
749        client_id: ClientId,
750    ) -> Result<(String, Option<String>, Option<String>, Option<String>)> {
751        let region = app_state
752            .with_client_mut(client_id, |client| {
753                client
754                    .get_protocol_field("region")
755                    .and_then(|v| v.as_str())
756                    .map(|s| s.to_string())
757            })
758            .await
759            .flatten()
760            .context("No region found")?;
761
762        let endpoint_url = app_state
763            .with_client_mut(client_id, |client| {
764                client
765                    .get_protocol_field("endpoint_url")
766                    .and_then(|v| v.as_str())
767                    .map(|s| s.to_string())
768            })
769            .await
770            .flatten();
771
772        let access_key_id = app_state
773            .with_client_mut(client_id, |client| {
774                client
775                    .get_protocol_field("access_key_id")
776                    .and_then(|v| v.as_str())
777                    .map(|s| s.to_string())
778            })
779            .await
780            .flatten();
781
782        let secret_access_key = app_state
783            .with_client_mut(client_id, |client| {
784                client
785                    .get_protocol_field("secret_access_key")
786                    .and_then(|v| v.as_str())
787                    .map(|s| s.to_string())
788            })
789            .await
790            .flatten();
791
792        Ok((region, endpoint_url, access_key_id, secret_access_key))
793    }
794
795    /// Build AWS SDK config
796    async fn build_aws_config(
797        region: &str,
798        endpoint_url: Option<&str>,
799        access_key_id: Option<&str>,
800        secret_access_key: Option<&str>,
801    ) -> Result<aws_config::SdkConfig> {
802        use aws_config::BehaviorVersion;
803
804        crate::client::aws_support::validate_region("DynamoDB", region)?;
805        let mut config_loader = aws_config::defaults(BehaviorVersion::latest())
806            .region(aws_config::Region::new(region.to_string()));
807
808        // Set custom endpoint if provided (for DynamoDB Local or LocalStack)
809        if let Some(endpoint) = endpoint_url {
810            config_loader = config_loader.endpoint_url(endpoint);
811        }
812
813        // Set credentials if provided
814        if let (Some(key_id), Some(secret_key)) = (access_key_id, secret_access_key) {
815            use aws_config::meta::credentials::CredentialsProviderChain;
816            use aws_credential_types::Credentials;
817
818            let credentials = Credentials::new(
819                key_id,
820                secret_key,
821                None, // session token
822                None, // expiry
823                "netget_dynamodb_client",
824            );
825
826            let provider = CredentialsProviderChain::first_try(
827                "Static",
828                aws_credential_types::provider::SharedCredentialsProvider::new(credentials),
829            );
830
831            config_loader = config_loader.credentials_provider(provider);
832        }
833
834        Ok(config_loader.load().await)
835    }
836
837    /// Convert one typed attribute value (`{"S": "x"}`, `{"N": "1"}`, …) into the SDK type.
838    ///
839    /// Returns `Err` rather than `None`, and the callers propagate it. Both used to *skip*
840    /// what this could not convert, so a `put_item` carrying a Map or List attribute wrote
841    /// an item missing that attribute and answered `{"written": true}`, and a `get_item`
842    /// whose key used one sent a partial key — silently, with nothing logged. Refusing is
843    /// the fail-closed choice and the model is told what it got wrong.
844    fn json_to_attribute_value(
845        name: &str,
846        json: &serde_json::Value,
847    ) -> Result<aws_sdk_dynamodb::types::AttributeValue> {
848        use aws_sdk_dynamodb::types::AttributeValue;
849
850        let map = json.as_object().with_context(|| {
851            format!(
852                "Attribute '{name}' must be a DynamoDB typed value such as {{\"S\": \"text\"}}, \
853                 got {json}"
854            )
855        })?;
856        if map.len() != 1 {
857            anyhow::bail!(
858                "Attribute '{name}' must carry exactly one type key (\"S\", \"N\", \"B\", \
859                 \"BOOL\" or \"NULL\"), got {json}"
860            );
861        }
862        let (type_key, value) = map.iter().next().expect("length checked above");
863
864        let wrong_shape = |expected: &str| {
865            anyhow::anyhow!("Attribute '{name}' has type {type_key} so its value must be {expected}, got {value}")
866        };
867
868        match type_key.as_str() {
869            "S" => Ok(AttributeValue::S(
870                value
871                    .as_str()
872                    .ok_or_else(|| wrong_shape("a string"))?
873                    .to_string(),
874            )),
875            "N" => Ok(AttributeValue::N(
876                value
877                    .as_str()
878                    .ok_or_else(|| wrong_shape("a string holding a number, e.g. \"123\""))?
879                    .to_string(),
880            )),
881            "B" => {
882                let encoded = value
883                    .as_str()
884                    .ok_or_else(|| wrong_shape("a base64 string"))?;
885                // A decode failure used to become an empty blob: an unreadable value was
886                // written to the table as a present, empty one.
887                let bytes = base64::Engine::decode(
888                    &base64::engine::general_purpose::STANDARD,
889                    encoded,
890                )
891                .with_context(|| {
892                    format!("Attribute '{name}' is type B, so its value must be standard base64")
893                })?;
894                Ok(AttributeValue::B(Blob::new(bytes)))
895            }
896            "BOOL" => Ok(AttributeValue::Bool(
897                value
898                    .as_bool()
899                    .ok_or_else(|| wrong_shape("true or false"))?,
900            )),
901            "NULL" => Ok(AttributeValue::Null(true)),
902            other => anyhow::bail!(
903                "Attribute '{name}' uses DynamoDB type {other:?}, which this client does not \
904                 support. Supported: S (string), N (number as string), B (base64 bytes), BOOL, \
905                 NULL. Maps (M), lists (L) and sets (SS/NS/BS) are not implemented — do not \
906                 send them rather than expecting them to be dropped."
907            ),
908        }
909    }
910
911    /// Convert DynamoDB AttributeValue map to JSON
912    fn attribute_map_to_json(
913        map: &std::collections::HashMap<String, aws_sdk_dynamodb::types::AttributeValue>,
914    ) -> serde_json::Value {
915        use aws_sdk_dynamodb::types::AttributeValue;
916
917        let mut json_map = serde_json::Map::new();
918        for (key, value) in map {
919            let json_value = match value {
920                AttributeValue::S(s) => serde_json::json!({"S": s}),
921                AttributeValue::N(n) => serde_json::json!({"N": n}),
922                AttributeValue::B(b) => {
923                    let b64 = base64::Engine::encode(
924                        &base64::engine::general_purpose::STANDARD,
925                        b.as_ref(),
926                    );
927                    serde_json::json!({"B": b64})
928                }
929                AttributeValue::Bool(b) => serde_json::json!({"BOOL": b}),
930                AttributeValue::Null(_) => serde_json::json!({"NULL": true}),
931                // Not `{"UNKNOWN": "unsupported_type"}`: that reads as a typed attribute
932                // and the model would copy it back into a put_item, where it is now
933                // rejected. Say what it is instead.
934                other => serde_json::json!({
935                    "netget_unsupported_attribute_type": format!("{other:?}")
936                }),
937            };
938            json_map.insert(key.clone(), json_value);
939        }
940        serde_json::Value::Object(json_map)
941    }
942
943    /// Call LLM with DynamoDB response
944    async fn call_llm_with_response(
945        client_id: ClientId,
946        operation: &str,
947        success: bool,
948        data: Option<serde_json::Value>,
949        error: Option<String>,
950        // `&Arc<AppState>` rather than `&AppState`: the follow-up actions below run
951        // through `apply_action`, which needs the Arc to hand on to `execute_operation`.
952        app_state: &Arc<AppState>,
953        llm_client: &OllamaClient,
954        status_tx: &mpsc::UnboundedSender<String>,
955    ) -> Result<()> {
956        if let Some(instruction) = app_state.get_instruction_for_client(client_id).await {
957            let protocol =
958                Arc::new(crate::client::dynamodb::actions::DynamoDbClientProtocol::new());
959
960            let mut event_data = serde_json::json!({
961                "operation": operation,
962                "success": success,
963            });
964
965            if let Some(d) = data {
966                event_data["data"] = d;
967            }
968            if let Some(e) = error {
969                event_data["error"] = serde_json::json!(e);
970            }
971
972            let event = Event::new(&DYNAMODB_CLIENT_RESPONSE_RECEIVED_EVENT, event_data);
973
974            let memory = app_state
975                .get_memory_for_client(client_id)
976                .await
977                .unwrap_or_default();
978
979            match call_llm_for_client(
980                llm_client,
981                app_state,
982                client_id.to_string(),
983                &instruction,
984                &memory,
985                Some(&event),
986                protocol.as_ref(),
987                status_tx,
988            )
989            .await
990            {
991                Ok(ClientLlmResult {
992                    actions,
993                    memory_updates,
994                }) => {
995                    // Update memory
996                    if let Some(mem) = memory_updates {
997                        app_state.set_memory_for_client(client_id, mem).await;
998                    }
999
1000                    // Execute the model's follow-up actions. They used to be discarded
1001                    // outright (`actions: _`), so answering dynamodb_response_received
1002                    // did nothing at all -- the point of raising the event.
1003                    //
1004                    // They go through `run_operation_once`, which raises no event, so a
1005                    // follow-up cannot trigger another response event and one operation
1006                    // cannot drive an unbounded LLM loop.
1007                    use crate::llm::actions::client_trait::Client;
1008                    for action in actions {
1009                        match protocol.execute_action(action.clone()) {
1010                            Ok(crate::llm::actions::client_trait::ClientActionResult::Custom {
1011                                name,
1012                                data,
1013                            }) => {
1014                                // `run_operation_once` raises no event and calls no
1015                                // LLM, so a follow-up cannot raise another response
1016                                // event -- that bounds the loop -- and the async type
1017                                // never becomes self-referential, which is what made
1018                                // the notify -> apply -> operation -> notify chain
1019                                // impossible for rustc to prove `Send`.
1020                                let outcome =
1021                                    Self::run_operation_once(client_id, &name, &data, app_state)
1022                                        .await;
1023                                info!(
1024                                    "DynamoDB client {} follow-up action {:?} -> {:?}",
1025                                    client_id, action, outcome
1026                                );
1027                            }
1028                            Ok(other) => info!(
1029                                "DynamoDB client {} follow-up produced no operation: {:?}",
1030                                client_id, other
1031                            ),
1032                            Err(e) => error!(
1033                                "DynamoDB client {} rejected its own follow-up action: {}",
1034                                client_id, e
1035                            ),
1036                        }
1037                    }
1038                }
1039                Err(e) => {
1040                    error!("LLM error for DynamoDB client {}: {}", client_id, e);
1041                }
1042            }
1043        }
1044
1045        Ok(())
1046    }
1047}