Skip to main content

netget/server/dynamo/
actions.rs

1//! DynamoDB protocol actions and event types
2//!
3//! Defines the actions the LLM can take in response to DynamoDB API requests.
4
5use crate::llm::actions::{
6    protocol_trait::{ActionResult, Protocol, Server},
7    ActionDefinition, Parameter,
8};
9use crate::protocol::log_template::LogTemplate;
10use crate::protocol::EventType;
11use crate::state::app_state::AppState;
12use anyhow::Result;
13use serde_json::{json, Value};
14use std::sync::LazyLock;
15
16/// DynamoDB protocol handler
17pub struct DynamoProtocol {
18    // Could store connection state here if needed
19}
20
21impl DynamoProtocol {
22    pub fn new() -> Self {
23        Self {}
24    }
25}
26
27/// DynamoDB request event - triggered when a DynamoDB API request is received
28pub static DYNAMO_REQUEST_EVENT: LazyLock<EventType> = LazyLock::new(|| {
29    EventType::new(
30        "dynamo_request",
31        "DynamoDB API request received",
32        json!({"type": "placeholder", "event_id": "dynamo_request"}),
33    )
34    .with_parameters(vec![
35        Parameter {
36            name: "operation".to_string(),
37            type_hint: "string".to_string(),
38            description: "DynamoDB operation (GetItem, PutItem, Query, etc.)".to_string(),
39            required: true,
40        },
41        Parameter {
42            name: "table_name".to_string(),
43            type_hint: "string".to_string(),
44            description: "Target table name (if available)".to_string(),
45            required: false,
46        },
47        Parameter {
48            name: "request_body".to_string(),
49            type_hint: "string".to_string(),
50            description: "JSON request body".to_string(),
51            required: true,
52        },
53    ])
54    .with_actions(vec![send_dynamo_response_action()])
55    .with_log_template(
56        LogTemplate::new()
57            .with_info("DynamoDB {operation}")
58            .with_debug("DynamoDB {operation} on {table_name}")
59            .with_trace("DynamoDB: {json_pretty(.)}"),
60    )
61});
62
63fn send_dynamo_response_action() -> ActionDefinition {
64    ActionDefinition {
65        name: "send_dynamo_response".to_string(),
66        description: "Send DynamoDB JSON response with HTTP status code".to_string(),
67        parameters: vec![
68            Parameter {
69                name: "status_code".to_string(),
70                type_hint: "number".to_string(),
71                description: "HTTP status code (200, 400, 500, etc.)".to_string(),
72                required: true,
73            },
74            Parameter {
75                name: "body".to_string(),
76                type_hint: "string".to_string(),
77                description: "JSON response body".to_string(),
78                required: true,
79            },
80        ],
81        example: serde_json::json!({
82            "type": "send_dynamo_response",
83            "status_code": 200,
84            "body": "{\"Item\": {\"id\": {\"S\": \"user-123\"}, \"name\": {\"S\": \"Alice\"}}}"
85        }),
86        log_template: Some(
87            LogTemplate::new()
88                .with_info("-> DynamoDB response (HTTP {status_code})")
89                .with_debug("DynamoDB send_dynamo_response: status={status_code}"),
90        ),
91    }
92}
93
94/// Read and validate the model-supplied `status_code`.
95///
96/// The old `as u64 as u16` cast wrapped silently, and out-of-range values reached
97/// `Response::builder().status()` where an `.unwrap()` turned them into a panic that
98/// killed the connection task. Reject them here, where the message reaches the model.
99fn parse_status_code(action: &Value) -> Result<u16> {
100    let raw = action
101        .get("status_code")
102        .and_then(|v| v.as_u64())
103        .ok_or_else(|| anyhow::anyhow!("Missing or invalid status_code: expected a number"))?;
104
105    if !(100..=599).contains(&raw) {
106        return Err(anyhow::anyhow!(
107            "Invalid status_code {raw}: must be an HTTP status between 100 and 599. \
108             DynamoDB uses 200 for success and 400 with an \
109             {{\"__type\": ..., \"message\": ...}} body for client errors."
110        ));
111    }
112
113    Ok(raw as u16)
114}
115
116pub fn get_dynamo_event_types() -> Vec<EventType> {
117    vec![DYNAMO_REQUEST_EVENT.clone()]
118}
119
120// Implement Protocol trait (common functionality)
121impl Protocol for DynamoProtocol {
122    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
123        // No async actions for DynamoDB currently
124        vec![]
125    }
126    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
127        vec![send_dynamo_response_action()]
128    }
129    fn protocol_name(&self) -> &'static str {
130        "DynamoDB"
131    }
132    fn get_event_types(&self) -> Vec<EventType> {
133        get_dynamo_event_types()
134    }
135    fn stack_name(&self) -> &'static str {
136        "ETH>IP>TCP>HTTP>DYNAMODB"
137    }
138    fn keywords(&self) -> Vec<&'static str> {
139        // Include the full "dynamodb" spelling: the bare "dynamo" keyword never matched
140        // "dynamodb" in free text because the trailing "db" broke the word boundary, so
141        // "emulate a DynamoDB table" resolved to nothing.
142        vec!["dynamo", "dynamodb", "dynamo db", "aws dynamodb"]
143    }
144    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
145        use crate::protocol::metadata::{DevelopmentState, ProtocolMetadataV2};
146
147        ProtocolMetadataV2::builder()
148            .state(DevelopmentState::Beta)
149            .well_known_port(8000)
150            .implementation("hyper v1.5 HTTP with manual DynamoDB API")
151            .llm_control("All DynamoDB operations (GetItem, PutItem, Query)")
152            .e2e_testing(
153                "TWO independent clients, neither #[ignore]d and neither able to skip. \
154                 (1) aws-sdk-dynamodb, the official Rust SDK, in \
155                 tests/server/dynamo/e2e_aws_sdk_test.rs: CreateTable, PutItem/GetItem and \
156                 UpdateItem complete through the SDK. \
157                 (2) the real `aws` CLI (botocore, Python) in \
158                 tests/server/dynamo/real_client_test.rs: ListTables, PutItem and GetItem, \
159                 asserting the TYPED attribute values it rendered -- a string under S, a number \
160                 under N, a boolean under BOOL. That is the check worth having, because \
161                 DynamoDB numbers cross the wire as STRINGS under an N tag and botocore rejects \
162                 an item whose tags it does not recognise rather than rendering it as text. \
163                 Verified by sending `\"count\":{\"N\":7}` as a JSON number, which the test \
164                 then reports. \
165                 THE TEST CANNOT REACH REAL AWS, and for this protocol that is not abstract: \
166                 the project CLAUDE.md records that this protocol's CLIENT once dropped its \
167                 target, let the SDK resolve the production endpoint and signed with ambient \
168                 credentials. --endpoint-url is passed on every invocation, the port is asserted \
169                 non-zero before the CLI is spawned, and credentials, region, profile and the \
170                 EC2 metadata service are overridden in the child's environment. \
171                 UNPROVEN, and worth saying plainly: NO SIGNATURE IS VALIDATED -- every request \
172                 is served unconditionally and neither Authorization nor X-Amz-Date reaches the \
173                 event, so the model cannot make that decision either. Also unproven: Query and \
174                 Scan, conditional writes, transactions, streams, and pagination.",
175            )
176            .notes("Virtual data (no persistence)")
177            .max_inbound_bytes(crate::server::dynamo::MAX_REQUEST_BYTES)
178            .request_only("DynamoDB is HTTP request/response; a server cannot send a peer anything unprompted")
179            .build()
180    }
181    fn description(&self) -> &'static str {
182        "DynamoDB-compatible database server"
183    }
184    fn example_prompt(&self) -> &'static str {
185        "Start a DynamoDB-compatible server on port 8000"
186    }
187    fn group_name(&self) -> &'static str {
188        "Database"
189    }
190
191    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
192        use crate::llm::actions::StartupExamples;
193        use serde_json::json;
194
195        // Deterministic DynamoDB (JSON protocol) replies routed on the operation.
196        // Items use DynamoDB's typed attribute-value shape. No LLM call.
197        let script = r#"import json, sys
198data = json.load(sys.stdin)
199event = data["event"]
200if data["event_type_id"] == "dynamo_request":
201    op = event.get("operation", "")
202    if op == "GetItem":
203        body = json.dumps({"Item": {"id": {"S": "item-1"},
204                                    "name": {"S": "Example"}}})
205    else:
206        body = json.dumps({})
207    actions = [{"type": "send_dynamo_response", "status_code": 200, "body": body}]
208else:
209    actions = []
210print(json.dumps({"actions": actions}))"#;
211
212        StartupExamples::new(
213            // LLM mode: maintain a real keyed table across requests.
214            json!({
215                "type": "open_server",
216                "port": 8000,
217                "base_stack": "dynamo",
218                "instruction": "Emulate a DynamoDB table 'Users' keyed by userId. On PutItem remember the item; on GetItem return the stored item for that key (or an empty response if absent); on Query return every remembered item whose userId matches. Format all responses in DynamoDB JSON with typed attribute values."
219            }),
220            // Script mode: fixed typed responses per operation, no LLM call.
221            json!({
222                "type": "open_server",
223                "port": 8000,
224                "base_stack": "dynamo",
225                "event_handlers": [{
226                    "event_pattern": "dynamo_request",
227                    "handler": {
228                        "type": "script",
229                        "language": "python",
230                        "code": script
231                    }
232                }]
233            }),
234            // Static mode: Fixed responses
235            json!({
236                "type": "open_server",
237                "port": 8000,
238                "base_stack": "dynamo",
239                "event_handlers": [{
240                    "event_pattern": "dynamo_request",
241                    "handler": {
242                        "type": "static",
243                        "actions": [{
244                            "type": "send_dynamo_response",
245                            "status_code": 200,
246                            "body": "{}"
247                        }]
248                    }
249                }]
250            }),
251        )
252    }
253}
254
255// Implement Server trait (server-specific functionality)
256impl Server for DynamoProtocol {
257    fn spawn(
258        &self,
259        ctx: crate::protocol::SpawnContext,
260    ) -> std::pin::Pin<
261        Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
262    > {
263        Box::pin(async move {
264            use crate::server::dynamo::DynamoServer;
265            DynamoServer::spawn_with_llm_actions(
266                ctx.legacy_listen_addr(),
267                ctx.llm_client,
268                ctx.state,
269                ctx.status_tx,
270                false,
271                ctx.server_id,
272            )
273            .await
274        })
275    }
276    fn execute_action(&self, action: Value) -> Result<ActionResult> {
277        let action_type = action
278            .get("type")
279            .and_then(|v| v.as_str())
280            .ok_or_else(|| anyhow::anyhow!("Missing action type"))?;
281
282        match action_type {
283            "send_dynamo_response" => {
284                let status_code = parse_status_code(&action)?;
285
286                let body = action
287                    .get("body")
288                    .and_then(|v| v.as_str())
289                    .ok_or_else(|| anyhow::anyhow!("Missing body"))?
290                    .to_string();
291
292                Ok(ActionResult::Custom {
293                    name: "dynamo_response".to_string(),
294                    data: json!({
295                        "status": status_code,
296                        "body": body
297                    }),
298                })
299            }
300            _ => Err(anyhow::anyhow!("Unknown action type: {}", action_type)),
301        }
302    }
303}