Skip to main content

netget/client/couchdb/
actions.rs

1//! CouchDB client protocol actions implementation
2
3use crate::llm::actions::{
4    client_trait::{Client, ClientActionResult},
5    protocol_trait::Protocol,
6    ActionDefinition, Parameter,
7};
8use crate::protocol::EventType;
9use crate::state::app_state::AppState;
10use anyhow::Result;
11use serde_json::json;
12use std::sync::LazyLock;
13
14/// CouchDB client connected event
15pub static COUCHDB_CLIENT_CONNECTED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
16    EventType::new(
17        "couchdb_connected",
18        "CouchDB client successfully connected to server",
19        json!({"type": "list_databases"}),
20    )
21    .with_parameters(vec![
22        Parameter {
23            name: "remote_addr".to_string(),
24            type_hint: "string".to_string(),
25            description: "CouchDB server address".to_string(),
26            required: true,
27        },
28        Parameter {
29            name: "server_info".to_string(),
30            type_hint: "object".to_string(),
31            description: "Server welcome information (version, etc.)".to_string(),
32            required: false,
33        },
34    ])
35});
36
37/// CouchDB client response received event
38pub static COUCHDB_CLIENT_RESPONSE_RECEIVED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
39    EventType::new(
40        "couchdb_response_received",
41        "Response received from CouchDB server",
42        json!({"type": "get_document", "database": "mydb", "doc_id": "user1"}),
43    )
44    .with_parameters(vec![
45        Parameter {
46            name: "operation".to_string(),
47            type_hint: "string".to_string(),
48            description: "The operation that was performed".to_string(),
49            required: true,
50        },
51        Parameter {
52            name: "success".to_string(),
53            type_hint: "boolean".to_string(),
54            description: "Whether the operation succeeded".to_string(),
55            required: true,
56        },
57        Parameter {
58            name: "data".to_string(),
59            type_hint: "object".to_string(),
60            description: "Response data from the operation".to_string(),
61            required: false,
62        },
63        Parameter {
64            name: "error".to_string(),
65            type_hint: "string".to_string(),
66            description: "Error message if operation failed".to_string(),
67            required: false,
68        },
69    ])
70});
71
72/// CouchDB client conflict event
73pub static COUCHDB_CLIENT_CONFLICT_EVENT: LazyLock<EventType> = LazyLock::new(|| {
74    EventType::new(
75        "couchdb_conflict",
76        "Document update conflict detected (revision mismatch)",
77        json!({"type": "get_document", "database": "mydb", "doc_id": "user1"}),
78    )
79    .with_parameters(vec![
80        Parameter {
81            name: "database".to_string(),
82            type_hint: "string".to_string(),
83            description: "Database name".to_string(),
84            required: true,
85        },
86        Parameter {
87            name: "doc_id".to_string(),
88            type_hint: "string".to_string(),
89            description: "Document ID".to_string(),
90            required: true,
91        },
92        Parameter {
93            name: "expected_rev".to_string(),
94            type_hint: "string".to_string(),
95            description: "The revision that was expected".to_string(),
96            required: false,
97        },
98    ])
99});
100
101/// CouchDB client change detected event (from changes feed)
102pub static COUCHDB_CLIENT_CHANGE_DETECTED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
103    EventType::new(
104        "couchdb_change_detected",
105        "Document change detected in changes feed",
106        json!({"type": "get_document", "database": "mydb", "doc_id": "user1"}),
107    )
108    .with_parameters(vec![
109        Parameter {
110            name: "database".to_string(),
111            type_hint: "string".to_string(),
112            description: "Database name".to_string(),
113            required: true,
114        },
115        Parameter {
116            name: "seq".to_string(),
117            type_hint: "string".to_string(),
118            description: "Sequence number".to_string(),
119            required: true,
120        },
121        Parameter {
122            name: "doc_id".to_string(),
123            type_hint: "string".to_string(),
124            description: "Changed document ID".to_string(),
125            required: true,
126        },
127        Parameter {
128            name: "changes".to_string(),
129            type_hint: "array".to_string(),
130            description: "Array of revision changes".to_string(),
131            required: true,
132        },
133        Parameter {
134            name: "deleted".to_string(),
135            type_hint: "boolean".to_string(),
136            description: "Whether the document was deleted".to_string(),
137            required: false,
138        },
139    ])
140});
141
142/// CouchDB client protocol action handler
143pub struct CouchDbClientProtocol;
144
145impl CouchDbClientProtocol {
146    pub fn new() -> Self {
147        Self
148    }
149}
150
151// Implement Protocol trait (common functionality)
152impl Protocol for CouchDbClientProtocol {
153    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
154        vec![
155            ActionDefinition {
156                name: "create_database".to_string(),
157                description: "Create a new database".to_string(),
158                parameters: vec![Parameter {
159                    name: "database".to_string(),
160                    type_hint: "string".to_string(),
161                    description: "Database name".to_string(),
162                    required: true,
163                }],
164                example: json!({
165                    "type": "create_database",
166                    "database": "mydb"
167                }),
168                log_template: None,
169            },
170            ActionDefinition {
171                name: "delete_database".to_string(),
172                description: "Delete a database".to_string(),
173                parameters: vec![Parameter {
174                    name: "database".to_string(),
175                    type_hint: "string".to_string(),
176                    description: "Database name".to_string(),
177                    required: true,
178                }],
179                example: json!({
180                    "type": "delete_database",
181                    "database": "mydb"
182                }),
183                log_template: None,
184            },
185            ActionDefinition {
186                name: "list_databases".to_string(),
187                description: "List all databases".to_string(),
188                parameters: vec![],
189                example: json!({
190                    "type": "list_databases"
191                }),
192                log_template: None,
193            },
194            ActionDefinition {
195                name: "create_document".to_string(),
196                description: "Create a new document".to_string(),
197                parameters: vec![
198                    Parameter {
199                        name: "database".to_string(),
200                        type_hint: "string".to_string(),
201                        description: "Database name".to_string(),
202                        required: true,
203                    },
204                    Parameter {
205                        name: "doc_id".to_string(),
206                        type_hint: "string".to_string(),
207                        description: "Document ID (optional, auto-generated if not provided)"
208                            .to_string(),
209                        required: false,
210                    },
211                    Parameter {
212                        name: "document".to_string(),
213                        type_hint: "object".to_string(),
214                        description: "Document data as JSON object".to_string(),
215                        required: true,
216                    },
217                ],
218                example: json!({
219                    "type": "create_document",
220                    "database": "mydb",
221                    "doc_id": "user1",
222                    "document": {"name": "Alice", "age": 30}
223                }),
224                log_template: None,
225            },
226            ActionDefinition {
227                name: "get_document".to_string(),
228                description: "Retrieve a document".to_string(),
229                parameters: vec![
230                    Parameter {
231                        name: "database".to_string(),
232                        type_hint: "string".to_string(),
233                        description: "Database name".to_string(),
234                        required: true,
235                    },
236                    Parameter {
237                        name: "doc_id".to_string(),
238                        type_hint: "string".to_string(),
239                        description: "Document ID".to_string(),
240                        required: true,
241                    },
242                ],
243                example: json!({
244                    "type": "get_document",
245                    "database": "mydb",
246                    "doc_id": "user1"
247                }),
248                log_template: None,
249            },
250            ActionDefinition {
251                name: "update_document".to_string(),
252                description: "Update an existing document (requires current revision)".to_string(),
253                parameters: vec![
254                    Parameter {
255                        name: "database".to_string(),
256                        type_hint: "string".to_string(),
257                        description: "Database name".to_string(),
258                        required: true,
259                    },
260                    Parameter {
261                        name: "doc_id".to_string(),
262                        type_hint: "string".to_string(),
263                        description: "Document ID".to_string(),
264                        required: true,
265                    },
266                    Parameter {
267                        name: "document".to_string(),
268                        type_hint: "object".to_string(),
269                        description: "Updated document data (must include _rev)".to_string(),
270                        required: true,
271                    },
272                ],
273                example: json!({
274                    "type": "update_document",
275                    "database": "mydb",
276                    "doc_id": "user1",
277                    "document": {"_rev": "1-abc", "name": "Alice", "age": 31}
278                }),
279                log_template: None,
280            },
281            ActionDefinition {
282                name: "delete_document".to_string(),
283                description: "Delete a document (requires current revision)".to_string(),
284                parameters: vec![
285                    Parameter {
286                        name: "database".to_string(),
287                        type_hint: "string".to_string(),
288                        description: "Database name".to_string(),
289                        required: true,
290                    },
291                    Parameter {
292                        name: "doc_id".to_string(),
293                        type_hint: "string".to_string(),
294                        description: "Document ID".to_string(),
295                        required: true,
296                    },
297                    Parameter {
298                        name: "rev".to_string(),
299                        type_hint: "string".to_string(),
300                        description: "Current document revision".to_string(),
301                        required: true,
302                    },
303                ],
304                example: json!({
305                    "type": "delete_document",
306                    "database": "mydb",
307                    "doc_id": "user1",
308                    "rev": "2-abc"
309                }),
310                log_template: None,
311            },
312            ActionDefinition {
313                name: "bulk_docs".to_string(),
314                description: "Perform bulk document operations".to_string(),
315                parameters: vec![
316                    Parameter {
317                        name: "database".to_string(),
318                        type_hint: "string".to_string(),
319                        description: "Database name".to_string(),
320                        required: true,
321                    },
322                    Parameter {
323                        name: "docs".to_string(),
324                        type_hint: "array".to_string(),
325                        description: "Array of documents to create/update".to_string(),
326                        required: true,
327                    },
328                ],
329                example: json!({
330                    "type": "bulk_docs",
331                    "database": "mydb",
332                    "docs": [
333                        {"_id": "doc1", "name": "Alice"},
334                        {"_id": "doc2", "name": "Bob"}
335                    ]
336                }),
337                log_template: None,
338            },
339            ActionDefinition {
340                name: "list_documents".to_string(),
341                description: "List all documents in a database".to_string(),
342                parameters: vec![
343                    Parameter {
344                        name: "database".to_string(),
345                        type_hint: "string".to_string(),
346                        description: "Database name".to_string(),
347                        required: true,
348                    },
349                    Parameter {
350                        name: "include_docs".to_string(),
351                        type_hint: "boolean".to_string(),
352                        description: "Whether to include full document content".to_string(),
353                        required: false,
354                    },
355                ],
356                example: json!({
357                    "type": "list_documents",
358                    "database": "mydb",
359                    "include_docs": false
360                }),
361                log_template: None,
362            },
363            ActionDefinition {
364                name: "query_view".to_string(),
365                description: "Query a MapReduce view".to_string(),
366                parameters: vec![
367                    Parameter {
368                        name: "database".to_string(),
369                        type_hint: "string".to_string(),
370                        description: "Database name".to_string(),
371                        required: true,
372                    },
373                    Parameter {
374                        name: "design_doc".to_string(),
375                        type_hint: "string".to_string(),
376                        description: "Design document name".to_string(),
377                        required: true,
378                    },
379                    Parameter {
380                        name: "view_name".to_string(),
381                        type_hint: "string".to_string(),
382                        description: "View name".to_string(),
383                        required: true,
384                    },
385                    Parameter {
386                        name: "params".to_string(),
387                        type_hint: "object".to_string(),
388                        description: "Query parameters (key, limit, skip, etc.)".to_string(),
389                        required: false,
390                    },
391                ],
392                example: json!({
393                    "type": "query_view",
394                    "database": "mydb",
395                    "design_doc": "users",
396                    "view_name": "by_age",
397                    "params": {"limit": 10}
398                }),
399                log_template: None,
400            },
401            ActionDefinition {
402                name: "watch_changes".to_string(),
403                description: "Start watching changes feed".to_string(),
404                parameters: vec![
405                    Parameter {
406                        name: "database".to_string(),
407                        type_hint: "string".to_string(),
408                        description: "Database name".to_string(),
409                        required: true,
410                    },
411                    Parameter {
412                        name: "since".to_string(),
413                        type_hint: "string".to_string(),
414                        description: "Start sequence (or 'now')".to_string(),
415                        required: false,
416                    },
417                    Parameter {
418                        name: "feed".to_string(),
419                        type_hint: "string".to_string(),
420                        description: "Feed type: normal, longpoll, or continuous".to_string(),
421                        required: false,
422                    },
423                ],
424                example: json!({
425                    "type": "watch_changes",
426                    "database": "mydb",
427                    "since": "now",
428                    "feed": "longpoll"
429                }),
430                log_template: None,
431            },
432            ActionDefinition {
433                name: "disconnect".to_string(),
434                description: "Disconnect from the CouchDB server".to_string(),
435                parameters: vec![],
436                example: json!({
437                    "type": "disconnect"
438                }),
439                log_template: None,
440            },
441            // Declared, not just accepted. The executor took any name at all, so this appeared
442            // to work while being advertised nowhere - the model could only reach it by
443            // guessing, and every other guess was swallowed just as silently.
444            ActionDefinition {
445                name: "wait_for_more".to_string(),
446                description: "Do nothing and wait for the next response. Correct when the \
447                    previous operation needs no follow-up."
448                    .to_string(),
449                parameters: vec![],
450                example: json!({
451                    "type": "wait_for_more"
452                }),
453                log_template: None,
454            },
455        ]
456    }
457
458    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
459        // Sync actions are same as async for CouchDB client (response-driven)
460        self.get_async_actions(&AppState::new())
461    }
462
463    fn protocol_name(&self) -> &'static str {
464        "CouchDB"
465    }
466
467    fn get_event_types(&self) -> Vec<EventType> {
468        vec![
469            COUCHDB_CLIENT_CONNECTED_EVENT.clone(),
470            COUCHDB_CLIENT_RESPONSE_RECEIVED_EVENT.clone(),
471            COUCHDB_CLIENT_CONFLICT_EVENT.clone(),
472            COUCHDB_CLIENT_CHANGE_DETECTED_EVENT.clone(),
473        ]
474    }
475
476    fn stack_name(&self) -> &'static str {
477        "ETH>IP>TCP>HTTP>COUCHDB"
478    }
479
480    fn keywords(&self) -> Vec<&'static str> {
481        vec!["couchdb client", "connect to couchdb", "couchdb"]
482    }
483
484    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
485        use crate::protocol::metadata::{DevelopmentState, ProtocolMetadataV2};
486
487        ProtocolMetadataV2::builder()
488            .state(DevelopmentState::Experimental)
489            .implementation("couch_rs library with LLM action control")
490            .llm_control("Full control over CouchDB operations")
491            .e2e_testing("NetGet CouchDB server")
492            .build()
493    }
494
495    fn description(&self) -> &'static str {
496        "CouchDB client for document database operations"
497    }
498
499    fn example_prompt(&self) -> &'static str {
500        "Connect to CouchDB at localhost:5984 and create a database called 'myapp'"
501    }
502
503    fn group_name(&self) -> &'static str {
504        "Database"
505    }
506
507    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
508        use crate::llm::actions::StartupExamples;
509        use serde_json::json;
510
511        StartupExamples::new(
512            // LLM mode: LLM controls CouchDB operations
513            json!({
514                "type": "open_client",
515                "remote_addr": "localhost:5984",
516                "base_stack": "couchdb",
517                "instruction": "List all databases and then get documents from the 'users' database"
518            }),
519            // Script mode: Code-based deterministic responses
520            json!({
521                "type": "open_client",
522                "remote_addr": "localhost:5984",
523                "base_stack": "couchdb",
524                "event_handlers": [{
525                    "event_pattern": "couchdb_response_received",
526                    "handler": {
527                        "type": "script",
528                        "language": "python",
529                        "code": "<couchdb_client_handler>"
530                    }
531                }]
532            }),
533            // Static mode: Fixed CouchDB list databases on connect
534            json!({
535                "type": "open_client",
536                "remote_addr": "localhost:5984",
537                "base_stack": "couchdb",
538                "event_handlers": [
539                    {
540                        "event_pattern": "couchdb_connected",
541                        "handler": {
542                            "type": "static",
543                            "actions": [{
544                                "type": "list_databases"
545                            }]
546                        }
547                    },
548                    {
549                        "event_pattern": "couchdb_response_received",
550                        "handler": {
551                            "type": "static",
552                            "actions": [{
553                                "type": "disconnect"
554                            }]
555                        }
556                    }
557                ]
558            }),
559        )
560    }
561
562    fn get_startup_parameters(&self) -> Vec<crate::llm::actions::ParameterDefinition> {
563        vec![
564            crate::llm::actions::ParameterDefinition {
565                name: "username".to_string(),
566                type_hint: "string".to_string(),
567                description: "CouchDB username (for basic auth)".to_string(),
568                required: false,
569                example: json!("admin"),
570                default: None,
571            },
572            crate::llm::actions::ParameterDefinition {
573                name: "password".to_string(),
574                type_hint: "string".to_string(),
575                description: "CouchDB password (for basic auth)".to_string(),
576                required: false,
577                example: json!("password"),
578                default: None,
579            },
580        ]
581    }
582}
583
584// Implement Client trait (client-specific functionality)
585impl Client for CouchDbClientProtocol {
586    fn connect(
587        &self,
588        ctx: crate::protocol::ConnectContext,
589    ) -> std::pin::Pin<
590        Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
591    > {
592        Box::pin(async move {
593            use crate::client::couchdb::CouchDbClient;
594
595            let username = ctx
596                .startup_params
597                .as_ref()
598                .map(|p| p.get_optional_string("username"))
599                .transpose()?
600                .flatten();
601
602            let password = ctx
603                .startup_params
604                .as_ref()
605                .map(|p| p.get_optional_string("password"))
606                .transpose()?
607                .flatten();
608
609            CouchDbClient::connect_with_llm_actions(
610                ctx.remote_addr,
611                ctx.llm_client,
612                ctx.state,
613                ctx.status_tx,
614                ctx.client_id,
615                username,
616                password,
617            )
618            .await
619        })
620    }
621
622    fn execute_action(&self, action: serde_json::Value) -> Result<ClientActionResult> {
623        let action_type = action
624            .get("type")
625            .and_then(|v| v.as_str())
626            .ok_or_else(|| anyhow::anyhow!("Missing action type"))?;
627
628        // Execution happens in the read loop in mod.rs, which dispatches on this name - so an
629        // unrecognised one is passed on and quietly ignored there. Validating here instead
630        // means a typo, an action from another protocol, or a common action this client cannot
631        // run is reported to the model, which then gets a chance to correct it. Returning Ok
632        // for everything also stopped the repair loop from ever firing.
633        const COUCHDB_CLIENT_ACTIONS: &[&str] = &[
634            "create_database",
635            "delete_database",
636            "list_databases",
637            "create_document",
638            "get_document",
639            "update_document",
640            "delete_document",
641            "list_documents",
642            "bulk_docs",
643            "query_view",
644            "watch_changes",
645            "disconnect",
646            "wait_for_more",
647        ];
648
649        if !COUCHDB_CLIENT_ACTIONS.contains(&action_type) {
650            return Err(anyhow::anyhow!(
651                "Unknown CouchDB client action '{}'. Valid actions: {}",
652                action_type,
653                COUCHDB_CLIENT_ACTIONS.join(", ")
654            ));
655        }
656
657        Ok(ClientActionResult::Custom {
658            name: action_type.to_string(),
659            data: action,
660        })
661    }
662}