Skip to main content

netget/server/couchdb/
actions.rs

1//! CouchDB protocol actions and event types
2//!
3//! Defines the actions the LLM can take in response to CouchDB API requests.
4
5use crate::llm::actions::protocol_trait::{ActionResult, Protocol, Server};
6use crate::llm::actions::{ActionDefinition, Parameter};
7use crate::protocol::log_template::LogTemplate;
8use crate::protocol::EventType;
9use crate::state::app_state::AppState;
10use anyhow::Result;
11use serde_json::{json, Value};
12use std::sync::LazyLock;
13
14/// CouchDB protocol handler
15pub struct CouchDbProtocol {
16    // Could store connection state here if needed
17}
18
19impl CouchDbProtocol {
20    pub fn new() -> Self {
21        Self {}
22    }
23}
24
25/// CouchDB request event - triggered when a CouchDB API request is received
26pub static COUCHDB_REQUEST_EVENT: LazyLock<EventType> = LazyLock::new(|| {
27    EventType::new(
28        "couchdb_request",
29        "CouchDB API request received",
30        json!({
31            "type": "send_server_info",
32            "version": "3.5.1"
33        }),
34    )
35    .with_parameters(vec![
36        Parameter {
37            name: "method".to_string(),
38            type_hint: "string".to_string(),
39            description: "HTTP method (GET, POST, PUT, DELETE, HEAD)".to_string(),
40            required: true,
41        },
42        Parameter {
43            name: "path".to_string(),
44            type_hint: "string".to_string(),
45            description: "Request path (e.g., /, /{db}, /{db}/{docid})".to_string(),
46            required: true,
47        },
48        Parameter {
49            name: "operation".to_string(),
50            type_hint: "string".to_string(),
51            description: "Detected operation type (server_info, db_create, doc_get, etc.)"
52                .to_string(),
53            required: true,
54        },
55        Parameter {
56            name: "database".to_string(),
57            type_hint: "string".to_string(),
58            description: "Database name (if applicable)".to_string(),
59            required: false,
60        },
61        Parameter {
62            name: "doc_id".to_string(),
63            type_hint: "string".to_string(),
64            description: "Document ID (if applicable)".to_string(),
65            required: false,
66        },
67        Parameter {
68            name: "query_params".to_string(),
69            type_hint: "object".to_string(),
70            description: "URL query parameters".to_string(),
71            required: false,
72        },
73        Parameter {
74            name: "request_body".to_string(),
75            type_hint: "string".to_string(),
76            description: "JSON request body".to_string(),
77            required: false,
78        },
79        Parameter {
80            name: "authorization".to_string(),
81            type_hint: "string".to_string(),
82            description: "Authorization header (for basic auth)".to_string(),
83            required: false,
84        },
85    ])
86    .with_actions(vec![
87        send_couchdb_response_action(),
88        send_server_info_action(),
89        send_db_info_action(),
90        send_doc_response_action(),
91        send_all_dbs_action(),
92        send_all_docs_action(),
93        send_bulk_docs_response_action(),
94        send_view_response_action(),
95        send_changes_response_action(),
96        send_replication_response_action(),
97        send_auth_required_action(),
98    ])
99    .with_log_template(
100        LogTemplate::new()
101            .with_info("{client_ip} CouchDB {method} {path} -> {status} ({duration_ms}ms)")
102            .with_debug("CouchDB {operation}: {method} {path} db={database} doc={doc_id}")
103            .with_trace("CouchDB request: {json_pretty(.)}"),
104    )
105});
106
107fn send_couchdb_response_action() -> ActionDefinition {
108    ActionDefinition {
109        name: "send_couchdb_response".to_string(),
110        description: "Send generic CouchDB JSON response with HTTP status code".to_string(),
111        parameters: vec![
112            Parameter {
113                name: "status_code".to_string(),
114                type_hint: "number".to_string(),
115                description: "HTTP status code (200, 201, 400, 401, 404, 409, 500, etc.)"
116                    .to_string(),
117                required: true,
118            },
119            Parameter {
120                name: "body".to_string(),
121                type_hint: "string".to_string(),
122                description: "JSON response body".to_string(),
123                required: true,
124            },
125            Parameter {
126                name: "etag".to_string(),
127                type_hint: "string".to_string(),
128                description: "ETag header value (for document revisions)".to_string(),
129                required: false,
130            },
131        ],
132        example: serde_json::json!({
133            "type": "send_couchdb_response",
134            "status_code": 200,
135            "body": "{\"ok\": true}",
136            "etag": "\"1-abc123\""
137        }),
138        log_template: Some(
139            LogTemplate::new()
140                .with_info("-> {status_code} ({body_len}B)")
141                .with_debug("CouchDB response: {status_code}")
142                .with_trace("CouchDB response: {body}"),
143        ),
144    }
145}
146
147fn send_server_info_action() -> ActionDefinition {
148    ActionDefinition {
149        name: "send_server_info".to_string(),
150        description: "Send CouchDB server welcome/info response (GET /)".to_string(),
151        parameters: vec![Parameter {
152            name: "version".to_string(),
153            type_hint: "string".to_string(),
154            description: "CouchDB version number".to_string(),
155            required: false,
156        }],
157        example: serde_json::json!({
158            "type": "send_server_info",
159            "version": "3.5.1"
160        }),
161        log_template: Some(
162            LogTemplate::new()
163                .with_info("-> CouchDB {version}")
164                .with_debug("CouchDB server info: version={version}"),
165        ),
166    }
167}
168
169fn send_db_info_action() -> ActionDefinition {
170    ActionDefinition {
171        name: "send_db_info".to_string(),
172        description: "Send database information response".to_string(),
173        parameters: vec![
174            Parameter {
175                name: "db_name".to_string(),
176                type_hint: "string".to_string(),
177                description: "Database name".to_string(),
178                required: true,
179            },
180            Parameter {
181                name: "doc_count".to_string(),
182                type_hint: "number".to_string(),
183                description: "Number of documents in database".to_string(),
184                required: false,
185            },
186            Parameter {
187                name: "update_seq".to_string(),
188                type_hint: "string".to_string(),
189                description: "Update sequence number".to_string(),
190                required: false,
191            },
192        ],
193        example: serde_json::json!({
194            "type": "send_db_info",
195            "db_name": "mydb",
196            "doc_count": 42,
197            "update_seq": "42-abc"
198        }),
199        log_template: Some(
200            LogTemplate::new()
201                .with_info("-> db {db_name}: {doc_count} docs")
202                .with_debug("CouchDB db info: {db_name}, docs={doc_count}"),
203        ),
204    }
205}
206
207fn send_doc_response_action() -> ActionDefinition {
208    ActionDefinition {
209        name: "send_doc_response".to_string(),
210        description: "Send document response (GET, PUT, POST, DELETE)".to_string(),
211        parameters: vec![
212            Parameter {
213                name: "success".to_string(),
214                type_hint: "boolean".to_string(),
215                description: "Whether the operation succeeded".to_string(),
216                required: true,
217            },
218            Parameter {
219                name: "doc_id".to_string(),
220                type_hint: "string".to_string(),
221                description: "Document ID".to_string(),
222                required: true,
223            },
224            Parameter {
225                name: "rev".to_string(),
226                type_hint: "string".to_string(),
227                description: "Document revision (format: seq-hash, e.g., 1-abc123)".to_string(),
228                required: true,
229            },
230            Parameter {
231                name: "document".to_string(),
232                type_hint: "object".to_string(),
233                description: "Document data (for GET requests)".to_string(),
234                required: false,
235            },
236            Parameter {
237                name: "created".to_string(),
238                type_hint: "boolean".to_string(),
239                description: "Set true when this request stored a document - CouchDB answers \
240                              PUT /{db}/{docid} and POST /{db} with 201 Created, not 200. Leave \
241                              it out (or false) for GET and DELETE, which answer 200"
242                    .to_string(),
243                required: false,
244            },
245            Parameter {
246                name: "error".to_string(),
247                type_hint: "string".to_string(),
248                description: "Error type (conflict, not_found, etc.)".to_string(),
249                required: false,
250            },
251            Parameter {
252                name: "reason".to_string(),
253                type_hint: "string".to_string(),
254                description: "Human-readable error reason".to_string(),
255                required: false,
256            },
257        ],
258        example: serde_json::json!({
259            "type": "send_doc_response",
260            "success": true,
261            "doc_id": "user1",
262            "rev": "1-abc123",
263            "document": {"name": "Alice", "age": 30},
264            "created": false
265        }),
266        log_template: Some(
267            LogTemplate::new()
268                .with_info("-> doc {doc_id} rev={rev}")
269                .with_debug("CouchDB doc: {doc_id} success={success} rev={rev}"),
270        ),
271    }
272}
273
274fn send_all_dbs_action() -> ActionDefinition {
275    ActionDefinition {
276        name: "send_all_dbs".to_string(),
277        description: "Send list of all databases (GET /_all_dbs)".to_string(),
278        parameters: vec![Parameter {
279            name: "databases".to_string(),
280            type_hint: "array".to_string(),
281            description: "Array of database names".to_string(),
282            required: true,
283        }],
284        example: serde_json::json!({
285            "type": "send_all_dbs",
286            "databases": ["_replicator", "_users", "mydb", "testdb"]
287        }),
288        log_template: Some(
289            LogTemplate::new()
290                .with_info("-> {databases_len} databases")
291                .with_debug("CouchDB all_dbs: {databases_len} databases"),
292        ),
293    }
294}
295
296fn send_all_docs_action() -> ActionDefinition {
297    ActionDefinition {
298        name: "send_all_docs".to_string(),
299        description: "Send list of all documents in database (GET /{db}/_all_docs)".to_string(),
300        parameters: vec![
301            Parameter {
302                name: "total_rows".to_string(),
303                type_hint: "number".to_string(),
304                description: "Total number of documents".to_string(),
305                required: true,
306            },
307            Parameter {
308                name: "rows".to_string(),
309                type_hint: "array".to_string(),
310                description: "Array of document rows with id, key, value (rev)".to_string(),
311                required: true,
312            },
313        ],
314        example: serde_json::json!({
315            "type": "send_all_docs",
316            "total_rows": 2,
317            "rows": [
318                {"id": "doc1", "key": "doc1", "value": {"rev": "1-abc"}},
319                {"id": "doc2", "key": "doc2", "value": {"rev": "1-def"}}
320            ]
321        }),
322        log_template: Some(
323            LogTemplate::new()
324                .with_info("-> {total_rows} documents")
325                .with_debug("CouchDB all_docs: {total_rows} rows"),
326        ),
327    }
328}
329
330fn send_bulk_docs_response_action() -> ActionDefinition {
331    ActionDefinition {
332        name: "send_bulk_docs_response".to_string(),
333        description: "Send bulk documents operation results (POST /{db}/_bulk_docs)".to_string(),
334        parameters: vec![Parameter {
335            name: "results".to_string(),
336            type_hint: "array".to_string(),
337            description: "Array of results with ok, id, rev for each document".to_string(),
338            required: true,
339        }],
340        example: serde_json::json!({
341            "type": "send_bulk_docs_response",
342            "results": [
343                {"ok": true, "id": "doc1", "rev": "1-abc"},
344                {"ok": true, "id": "doc2", "rev": "1-def"}
345            ]
346        }),
347        log_template: Some(
348            LogTemplate::new()
349                .with_info("-> bulk: {results_len} docs")
350                .with_debug("CouchDB bulk_docs: {results_len} results"),
351        ),
352    }
353}
354
355fn send_view_response_action() -> ActionDefinition {
356    ActionDefinition {
357        name: "send_view_response".to_string(),
358        description: "Send view query results (MapReduce)".to_string(),
359        parameters: vec![
360            Parameter {
361                name: "total_rows".to_string(),
362                type_hint: "number".to_string(),
363                description: "Total number of rows".to_string(),
364                required: true,
365            },
366            Parameter {
367                name: "offset".to_string(),
368                type_hint: "number".to_string(),
369                description: "Offset in result set".to_string(),
370                required: false,
371            },
372            Parameter {
373                name: "rows".to_string(),
374                type_hint: "array".to_string(),
375                description: "Array of view rows with id, key, value".to_string(),
376                required: true,
377            },
378        ],
379        example: serde_json::json!({
380            "type": "send_view_response",
381            "total_rows": 2,
382            "offset": 0,
383            "rows": [
384                {"id": "user1", "key": 25, "value": "Alice"},
385                {"id": "user2", "key": 30, "value": "Bob"}
386            ]
387        }),
388        log_template: Some(
389            LogTemplate::new()
390                .with_info("-> view: {total_rows} rows")
391                .with_debug("CouchDB view: {total_rows} total, offset={offset}"),
392        ),
393    }
394}
395
396fn send_changes_response_action() -> ActionDefinition {
397    ActionDefinition {
398        name: "send_changes_response".to_string(),
399        description: "Send changes feed response (GET /{db}/_changes)".to_string(),
400        parameters: vec![
401            Parameter {
402                name: "results".to_string(),
403                type_hint: "array".to_string(),
404                description: "Array of change events with seq, id, changes".to_string(),
405                required: true,
406            },
407            Parameter {
408                name: "last_seq".to_string(),
409                type_hint: "string".to_string(),
410                description: "Last sequence number".to_string(),
411                required: true,
412            },
413            Parameter {
414                name: "pending".to_string(),
415                type_hint: "number".to_string(),
416                description: "Number of pending changes".to_string(),
417                required: false,
418            },
419        ],
420        example: serde_json::json!({
421            "type": "send_changes_response",
422            "results": [
423                {"seq": "1-abc", "id": "doc1", "changes": [{"rev": "1-xyz"}]},
424                {"seq": "2-def", "id": "doc2", "changes": [{"rev": "1-uvw"}]}
425            ],
426            "last_seq": "2-def",
427            "pending": 0
428        }),
429        log_template: Some(
430            LogTemplate::new()
431                .with_info("-> changes: {results_len} seq={last_seq}")
432                .with_debug("CouchDB changes: {results_len} results, last_seq={last_seq}"),
433        ),
434    }
435}
436
437fn send_replication_response_action() -> ActionDefinition {
438    ActionDefinition {
439        name: "send_replication_response".to_string(),
440        description: "Send replication protocol response".to_string(),
441        parameters: vec![
442            Parameter {
443                name: "history".to_string(),
444                type_hint: "array".to_string(),
445                description: "Replication history".to_string(),
446                required: false,
447            },
448            Parameter {
449                name: "session_id".to_string(),
450                type_hint: "string".to_string(),
451                description: "Replication session ID. Required: this response asserts the \
452                              replication ran, and a placeholder session id makes that \
453                              assertion on the handler's behalf"
454                    .to_string(),
455                required: true,
456            },
457            Parameter {
458                name: "source_last_seq".to_string(),
459                type_hint: "string".to_string(),
460                description: "Sequence the source has been replicated up to. Required: the \
461                              peer stores it as a checkpoint and resumes from it"
462                    .to_string(),
463                required: true,
464            },
465        ],
466        example: serde_json::json!({
467            "type": "send_replication_response",
468            "session_id": "abc123",
469            "source_last_seq": "10-xyz"
470        }),
471        log_template: Some(
472            LogTemplate::new()
473                .with_info("-> replication session={session_id}")
474                .with_debug("CouchDB replication: session={session_id}, seq={source_last_seq}"),
475        ),
476    }
477}
478
479fn send_auth_required_action() -> ActionDefinition {
480    ActionDefinition {
481        name: "send_auth_required".to_string(),
482        description: "Send 401 Unauthorized response (basic auth required)".to_string(),
483        parameters: vec![Parameter {
484            name: "realm".to_string(),
485            type_hint: "string".to_string(),
486            description: "Authentication realm".to_string(),
487            required: false,
488        }],
489        example: serde_json::json!({
490            "type": "send_auth_required",
491            "realm": "CouchDB"
492        }),
493        log_template: Some(
494            LogTemplate::new()
495                .with_info("-> 401 Unauthorized")
496                .with_debug("CouchDB auth required: realm={realm}"),
497        ),
498    }
499}
500
501/// Read and validate the model-supplied `status_code`.
502///
503/// The old `as u64 as u16` cast wrapped silently, and out-of-range values reached
504/// `Response::builder().status()` where an `.unwrap()` turned them into a panic that
505/// killed the connection task. Reject them here, where the message reaches the model.
506fn parse_status_code(action: &Value) -> Result<u16> {
507    let raw = action
508        .get("status_code")
509        .and_then(|v| v.as_u64())
510        .ok_or_else(|| anyhow::anyhow!("Missing or invalid status_code: expected a number"))?;
511
512    if !(100..=599).contains(&raw) {
513        return Err(anyhow::anyhow!(
514            "Invalid status_code {raw}: must be an HTTP status between 100 and 599. CouchDB \
515             uses 200 for success, 201 when a database or document is created, 404 when it \
516             does not exist and 409 when a document _rev does not match."
517        ));
518    }
519
520    Ok(raw as u16)
521}
522
523/// Read a count the action declares `required: true`.
524///
525/// `total_rows` is not decoration: a replicator and the Fauxton UI both use it to know how
526/// much of a database they have seen, so a defaulted 0 next to a non-empty `rows` array is a
527/// response that contradicts itself. Only the handler knows the real figure.
528fn required_count(action: &Value, act: &str, field: &str) -> Result<u64> {
529    action.get(field).and_then(|v| v.as_u64()).ok_or_else(|| {
530        anyhow::anyhow!(
531            "{act} requires `{field}` (a non-negative integer). It is a statement about the \
532             database that cannot be defaulted: 0 alongside a non-empty result set is a \
533             response no client can reconcile."
534        )
535    })
536}
537
538/// Read a non-empty string the action declares `required: true`.
539fn required_str<'a>(action: &'a Value, act: &str, field: &str) -> Result<&'a str> {
540    action
541        .get(field)
542        .and_then(|v| v.as_str())
543        .filter(|s| !s.trim().is_empty())
544        .ok_or_else(|| {
545            anyhow::anyhow!(
546                "{act} requires a non-empty `{field}`. It is checkpoint state a peer stores \
547                 and resumes from, so a placeholder is worse than a refusal."
548            )
549        })
550}
551
552/// Serialize a response body. Serializing a `serde_json::Value` cannot fail, so the
553/// fallback is unreachable - it exists only to keep this off the panic path.
554fn body_of(value: &Value) -> String {
555    serde_json::to_string_pretty(value).unwrap_or_else(|_| value.to_string())
556}
557
558pub fn get_couchdb_event_types() -> Vec<EventType> {
559    vec![COUCHDB_REQUEST_EVENT.clone()]
560}
561
562// Implement Protocol trait (common functionality)
563impl Protocol for CouchDbProtocol {
564    fn get_startup_parameters(&self) -> Vec<crate::llm::actions::ParameterDefinition> {
565        vec![
566            crate::llm::actions::ParameterDefinition {
567                name: "enable_auth".to_string(),
568                type_hint: "boolean".to_string(),
569                description: "Enable HTTP basic authentication (default: false)".to_string(),
570                required: false,
571                example: serde_json::json!(false),
572                default: None,
573            },
574            crate::llm::actions::ParameterDefinition {
575                name: "admin_username".to_string(),
576                type_hint: "string".to_string(),
577                description: "Admin username (if auth enabled)".to_string(),
578                required: false,
579                example: serde_json::json!("admin"),
580                default: None,
581            },
582            crate::llm::actions::ParameterDefinition {
583                name: "admin_password".to_string(),
584                type_hint: "string".to_string(),
585                description: "Admin password (if auth enabled)".to_string(),
586                required: false,
587                example: serde_json::json!("password"),
588                default: None,
589            },
590        ]
591    }
592
593    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
594        // No async actions for CouchDB currently
595        vec![]
596    }
597
598    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
599        vec![
600            send_couchdb_response_action(),
601            send_server_info_action(),
602            send_db_info_action(),
603            send_doc_response_action(),
604            send_all_dbs_action(),
605            send_all_docs_action(),
606            send_bulk_docs_response_action(),
607            send_view_response_action(),
608            send_changes_response_action(),
609            send_replication_response_action(),
610            send_auth_required_action(),
611        ]
612    }
613
614    fn protocol_name(&self) -> &'static str {
615        "CouchDB"
616    }
617
618    fn get_event_types(&self) -> Vec<EventType> {
619        get_couchdb_event_types()
620    }
621
622    fn stack_name(&self) -> &'static str {
623        "ETH>IP>TCP>HTTP>COUCHDB"
624    }
625
626    fn keywords(&self) -> Vec<&'static str> {
627        vec!["couchdb", "nosql", "document-database"]
628    }
629
630    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
631        use crate::protocol::metadata::{DevelopmentState, ProtocolMetadataV2};
632
633        ProtocolMetadataV2::builder()
634            .state(DevelopmentState::Experimental)
635            .well_known_port(5984)
636            .implementation("hyper v1.5 HTTP server with CouchDB REST API")
637            .llm_control("Database CRUD, document CRUD, views, changes, replication")
638            // `couch_rs` is what the CouchDB *client* protocol uses; no test has ever pointed
639            // it at this server. `tests/server/couchdb/e2e_test.rs` drives `reqwest`, which
640            // proves an HTTP server answers — not that the CouchDB API on top of it is right.
641            // That is why this stays Experimental: the ratings that require "works against
642            // real clients" mean a client that implements *this* protocol.
643            .e2e_testing("reqwest (a generic HTTP client); no CouchDB client has been used")
644            .notes(
645                "Virtual data (no persistence), LLM-controlled revisions and views. Request \
646                 bodies are bounded at http_common::MAX_REQUEST_BODY_BYTES and a larger one \
647                 is refused with 413. No attachment content action and no Mango queries",
648            )
649            .max_inbound_bytes(crate::server::couchdb::MAX_REQUEST_BODY_BYTES)
650            .request_only(
651                "CouchDB is HTTP request/response; a server cannot send a peer anything unprompted",
652            )
653            .build()
654    }
655
656    fn description(&self) -> &'static str {
657        "CouchDB document database"
658    }
659
660    fn example_prompt(&self) -> &'static str {
661        "Start a CouchDB server on port 5984"
662    }
663
664    fn group_name(&self) -> &'static str {
665        "Database"
666    }
667
668    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
669        use crate::llm::actions::StartupExamples;
670        use serde_json::json;
671
672        // Deterministic routing: welcome/version info for GET /, a canned
673        // {"ok": true} for everything else, with no LLM call.
674        let script = r#"import json, sys
675data = json.load(sys.stdin)
676event = data["event"]
677if data["event_type_id"] == "couchdb_request":
678    if event.get("path", "/") == "/":
679        actions = [{"type": "send_server_info", "version": "3.5.1"}]
680    else:
681        actions = [{"type": "send_couchdb_response", "status_code": 200,
682                    "body": '{"ok": true}'}]
683else:
684    actions = []
685print(json.dumps({"actions": actions}))"#;
686
687        StartupExamples::new(
688            // LLM mode: LLM handles all CouchDB responses intelligently
689            json!({
690                "type": "open_server",
691                "port": 5984,
692                "base_stack": "couchdb",
693                "instruction": "CouchDB document database handling REST API requests"
694            }),
695            // Script mode: Code-based deterministic responses
696            json!({
697                "type": "open_server",
698                "port": 5984,
699                "base_stack": "couchdb",
700                "event_handlers": [{
701                    "event_pattern": "couchdb_request",
702                    "handler": {
703                        "type": "script",
704                        "language": "python",
705                        "code": script
706                    }
707                }]
708            }),
709            // Static mode: Fixed responses
710            json!({
711                "type": "open_server",
712                "port": 5984,
713                "base_stack": "couchdb",
714                "event_handlers": [{
715                    "event_pattern": "couchdb_request",
716                    "handler": {
717                        "type": "static",
718                        "actions": [{
719                            "type": "send_server_info",
720                            "version": "3.5.1"
721                        }]
722                    }
723                }]
724            }),
725        )
726    }
727}
728
729// Implement Server trait (server-specific functionality)
730impl Server for CouchDbProtocol {
731    fn spawn(
732        &self,
733        ctx: crate::protocol::SpawnContext,
734    ) -> std::pin::Pin<
735        Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
736    > {
737        Box::pin(async move {
738            use crate::server::couchdb::CouchDbServer;
739
740            let enable_auth = ctx
741                .startup_params
742                .as_ref()
743                .map(|p| p.get_optional_bool("enable_auth"))
744                .transpose()?
745                .flatten()
746                .unwrap_or(false);
747
748            let admin_username = ctx
749                .startup_params
750                .as_ref()
751                .map(|p| p.get_optional_string("admin_username"))
752                .transpose()?
753                .flatten()
754                .unwrap_or_else(|| "admin".to_string());
755
756            let admin_password = ctx
757                .startup_params
758                .as_ref()
759                .map(|p| p.get_optional_string("admin_password"))
760                .transpose()?
761                .flatten()
762                .unwrap_or_else(|| "password".to_string());
763
764            CouchDbServer::spawn_with_llm_actions(
765                ctx.legacy_listen_addr(),
766                ctx.llm_client,
767                ctx.state,
768                ctx.status_tx,
769                ctx.server_id,
770                enable_auth,
771                admin_username,
772                admin_password,
773            )
774            .await
775        })
776    }
777
778    fn execute_action(&self, action: Value) -> Result<ActionResult> {
779        let action_type = action
780            .get("type")
781            .and_then(|v| v.as_str())
782            .ok_or_else(|| anyhow::anyhow!("Missing action type"))?;
783
784        match action_type {
785            "send_couchdb_response" => {
786                let status_code = parse_status_code(&action)?;
787
788                let body = action
789                    .get("body")
790                    .and_then(|v| v.as_str())
791                    .ok_or_else(|| anyhow::anyhow!("Missing body"))?
792                    .to_string();
793
794                let etag = action
795                    .get("etag")
796                    .and_then(|v| v.as_str())
797                    .map(|s| s.to_string());
798
799                Ok(ActionResult::Custom {
800                    name: "couchdb_response".to_string(),
801                    data: json!({
802                        "status": status_code,
803                        "body": body,
804                        "etag": etag
805                    }),
806                })
807            }
808            "send_server_info" => {
809                let version = action
810                    .get("version")
811                    .and_then(|v| v.as_str())
812                    .unwrap_or("3.5.1");
813
814                let response = serde_json::json!({
815                    "couchdb": "Welcome",
816                    "version": version,
817                    "git_sha": "netget-llm",
818                    "uuid": "netget-couchdb-uuid",
819                    "features": [
820                        "access-ready",
821                        "partitioned",
822                        "pluggable-storage-engines",
823                        "reshard",
824                        "scheduler"
825                    ],
826                    "vendor": {
827                        "name": "NetGet LLM CouchDB"
828                    }
829                });
830
831                Ok(ActionResult::Custom {
832                    name: "couchdb_response".to_string(),
833                    data: json!({
834                        "status": 200,
835                        "body": body_of(&response)
836                    }),
837                })
838            }
839            "send_db_info" => {
840                let db_name = action
841                    .get("db_name")
842                    .and_then(|v| v.as_str())
843                    .ok_or_else(|| anyhow::anyhow!("Missing db_name"))?;
844
845                let doc_count = action
846                    .get("doc_count")
847                    .and_then(|v| v.as_u64())
848                    .unwrap_or(0);
849
850                let update_seq = action
851                    .get("update_seq")
852                    .and_then(|v| v.as_str())
853                    .unwrap_or("0");
854
855                let response = serde_json::json!({
856                    "db_name": db_name,
857                    "update_seq": update_seq,
858                    "sizes": {
859                        "file": 0,
860                        "external": 0,
861                        "active": 0
862                    },
863                    "purge_seq": 0,
864                    "doc_del_count": 0,
865                    "doc_count": doc_count,
866                    "disk_format_version": 8,
867                    "compact_running": false,
868                    "cluster": {
869                        "q": 2,
870                        "n": 1,
871                        "w": 1,
872                        "r": 1
873                    },
874                    "instance_start_time": "0"
875                });
876
877                Ok(ActionResult::Custom {
878                    name: "couchdb_response".to_string(),
879                    data: json!({
880                        "status": 200,
881                        "body": body_of(&response)
882                    }),
883                })
884            }
885            "send_doc_response" => {
886                // Declared `required: true`. Defaulting to `true` meant an omitted or
887                // mistyped flag — `"false"` as a string, `0`, `null` — collapsed to success,
888                // so a model refusing a document write was reported to the client as having
889                // performed it.
890                let success = action
891                    .get("success")
892                    .and_then(|v| v.as_bool())
893                    .ok_or_else(|| {
894                        anyhow::anyhow!(
895                            "send_doc_response requires `success` (a JSON boolean, not a \
896                             string); it decides whether the document write is reported as \
897                             applied and must be stated explicitly"
898                        )
899                    })?;
900
901                let doc_id = action
902                    .get("doc_id")
903                    .and_then(|v| v.as_str())
904                    .ok_or_else(|| anyhow::anyhow!("Missing doc_id"))?;
905
906                let rev = action
907                    .get("rev")
908                    .and_then(|v| v.as_str())
909                    .ok_or_else(|| anyhow::anyhow!("Missing rev"))?;
910
911                if success {
912                    let document = action.get("document").cloned();
913
914                    let response = if let Some(doc) = document {
915                        // GET request - return full document
916                        let mut doc_obj = doc.clone();
917                        if let Some(obj) = doc_obj.as_object_mut() {
918                            obj.insert("_id".to_string(), json!(doc_id));
919                            obj.insert("_rev".to_string(), json!(rev));
920                        }
921                        doc_obj
922                    } else {
923                        // PUT/POST/DELETE request - return confirmation
924                        json!({
925                            "ok": true,
926                            "id": doc_id,
927                            "rev": rev
928                        })
929                    };
930
931                    // CouchDB answers a stored document with 201 Created; only reads and
932                    // deletes are 200. Answering 201 as 200 makes a client that branches on
933                    // the status treat a successful write as an unrecognised outcome.
934                    let created = action
935                        .get("created")
936                        .and_then(|v| v.as_bool())
937                        .unwrap_or(false);
938
939                    Ok(ActionResult::Custom {
940                        name: "couchdb_response".to_string(),
941                        data: json!({
942                            "status": if created { 201 } else { 200 },
943                            "body": body_of(&response),
944                            "etag": format!("\"{}\"", rev)
945                        }),
946                    })
947                } else {
948                    // Error response (conflict, not_found, etc.)
949                    let error = action
950                        .get("error")
951                        .and_then(|v| v.as_str())
952                        .unwrap_or("unknown_error");
953
954                    let reason = action
955                        .get("reason")
956                        .and_then(|v| v.as_str())
957                        .unwrap_or("Unknown error");
958
959                    let status = match error {
960                        "not_found" => 404,
961                        "conflict" => 409,
962                        "unauthorized" => 401,
963                        "forbidden" => 403,
964                        _ => 400,
965                    };
966
967                    let response = json!({
968                        "error": error,
969                        "reason": reason
970                    });
971
972                    Ok(ActionResult::Custom {
973                        name: "couchdb_response".to_string(),
974                        data: json!({
975                            "status": status,
976                            "body": body_of(&response)
977                        }),
978                    })
979                }
980            }
981            "send_all_dbs" => {
982                let databases = action
983                    .get("databases")
984                    .ok_or_else(|| anyhow::anyhow!("Missing databases"))?;
985
986                Ok(ActionResult::Custom {
987                    name: "couchdb_response".to_string(),
988                    data: json!({
989                        "status": 200,
990                        "body": body_of(databases)
991                    }),
992                })
993            }
994            "send_all_docs" => {
995                let rows = action
996                    .get("rows")
997                    .ok_or_else(|| anyhow::anyhow!("Missing rows"))?;
998                let total_rows = required_count(&action, "send_all_docs", "total_rows")?;
999
1000                let response = json!({
1001                    "total_rows": total_rows,
1002                    "offset": 0,
1003                    "rows": rows
1004                });
1005
1006                Ok(ActionResult::Custom {
1007                    name: "couchdb_response".to_string(),
1008                    data: json!({
1009                        "status": 200,
1010                        "body": body_of(&response)
1011                    }),
1012                })
1013            }
1014            "send_bulk_docs_response" => {
1015                let results = action
1016                    .get("results")
1017                    .ok_or_else(|| anyhow::anyhow!("Missing results"))?;
1018
1019                Ok(ActionResult::Custom {
1020                    name: "couchdb_response".to_string(),
1021                    data: json!({
1022                        "status": 201,
1023                        "body": body_of(results)
1024                    }),
1025                })
1026            }
1027            "send_view_response" => {
1028                let offset = action.get("offset").and_then(|v| v.as_u64()).unwrap_or(0);
1029
1030                let rows = action
1031                    .get("rows")
1032                    .ok_or_else(|| anyhow::anyhow!("Missing rows"))?;
1033                let total_rows = required_count(&action, "send_view_response", "total_rows")?;
1034
1035                let response = json!({
1036                    "total_rows": total_rows,
1037                    "offset": offset,
1038                    "rows": rows
1039                });
1040
1041                Ok(ActionResult::Custom {
1042                    name: "couchdb_response".to_string(),
1043                    data: json!({
1044                        "status": 200,
1045                        "body": body_of(&response)
1046                    }),
1047                })
1048            }
1049            "send_changes_response" => {
1050                let results = action
1051                    .get("results")
1052                    .ok_or_else(|| anyhow::anyhow!("Missing results"))?;
1053
1054                // Declared `required: true`. `last_seq` is the checkpoint a replicator
1055                // stores and resumes from, so defaulting it to "0" tells the peer it has
1056                // seen nothing — after handing it changes. A replicator that believes that
1057                // restarts from the beginning of the database on every pass, forever.
1058                let last_seq = required_str(&action, "send_changes_response", "last_seq")?;
1059
1060                let pending = action.get("pending").and_then(|v| v.as_u64()).unwrap_or(0);
1061
1062                let response = json!({
1063                    "results": results,
1064                    "last_seq": last_seq,
1065                    "pending": pending
1066                });
1067
1068                Ok(ActionResult::Custom {
1069                    name: "couchdb_response".to_string(),
1070                    data: json!({
1071                        "status": 200,
1072                        "body": body_of(&response)
1073                    }),
1074                })
1075            }
1076            "send_replication_response" => {
1077                // `{"ok": true}` is the most affirmative body CouchDB's replication protocol
1078                // has: it says the replication ran and checkpointed. With every parameter
1079                // optional and every default supplied, a bare `{"type":
1080                // "send_replication_response"}` produced it — complete with the literal
1081                // session id "abc123" and `source_last_seq: "0"` — for a replication nothing
1082                // performed. That is the OAuth2 fail-open shape: a model that meant to say
1083                // nothing said yes. Both checkpoint fields are now required, because only
1084                // the handler can know them.
1085                let history = action.get("history").cloned().unwrap_or_else(|| json!([]));
1086                let session_id = required_str(&action, "send_replication_response", "session_id")?;
1087                let source_last_seq =
1088                    required_str(&action, "send_replication_response", "source_last_seq")?;
1089
1090                let response = json!({
1091                    "ok": true,
1092                    "session_id": session_id,
1093                    "source_last_seq": source_last_seq,
1094                    "history": history
1095                });
1096
1097                Ok(ActionResult::Custom {
1098                    name: "couchdb_response".to_string(),
1099                    data: json!({
1100                        "status": 200,
1101                        "body": body_of(&response)
1102                    }),
1103                })
1104            }
1105            "send_auth_required" => {
1106                let realm = action
1107                    .get("realm")
1108                    .and_then(|v| v.as_str())
1109                    .unwrap_or("CouchDB");
1110
1111                let response = json!({
1112                    "error": "unauthorized",
1113                    "reason": "Authentication required"
1114                });
1115
1116                Ok(ActionResult::Custom {
1117                    name: "couchdb_response".to_string(),
1118                    data: json!({
1119                        "status": 401,
1120                        "body": body_of(&response),
1121                        "www_authenticate": format!("Basic realm=\"{}\"", realm)
1122                    }),
1123                })
1124            }
1125            _ => Err(anyhow::anyhow!("Unknown action type: {}", action_type)),
1126        }
1127    }
1128}