Skip to main content

netget/client/couchdb/
mod.rs

1//! CouchDB client implementation
2pub mod actions;
3
4pub use actions::CouchDbClientProtocol;
5
6use anyhow::{Context, Result};
7use std::net::SocketAddr;
8use std::sync::Arc;
9use tokio::sync::mpsc;
10use tracing::{error, info, warn};
11
12use crate::client::couchdb::actions::{
13    COUCHDB_CLIENT_CONNECTED_EVENT, COUCHDB_CLIENT_RESPONSE_RECEIVED_EVENT,
14};
15use crate::client::llm_budget::call_llm_for_client;
16use crate::llm::actions::client_trait::{Client, ClientActionResult};
17use crate::llm::actions::protocol_trait::Protocol;
18use crate::llm::ollama_client::OllamaClient;
19use crate::protocol::Event;
20use crate::state::app_state::AppState;
21use crate::state::client_handles::{ClientCommand, ClientSendOutcome};
22use crate::state::{AccessLogOwner, ClientId, ClientStatus};
23use crate::{console_error, console_info};
24
25/// CouchDB client that connects to a CouchDB server
26pub struct CouchDbClient;
27
28impl CouchDbClient {
29    /// Connect to a CouchDB server with integrated LLM actions
30    #[allow(clippy::too_many_arguments)]
31    pub async fn connect_with_llm_actions(
32        remote_addr: String,
33        llm_client: OllamaClient,
34        app_state: Arc<AppState>,
35        status_tx: mpsc::UnboundedSender<String>,
36        client_id: ClientId,
37        username: Option<String>,
38        password: Option<String>,
39    ) -> Result<SocketAddr> {
40        // Build CouchDB URL (add http:// if not present)
41        let url = if remote_addr.starts_with("http://") || remote_addr.starts_with("https://") {
42            remote_addr.clone()
43        } else {
44            format!("http://{}", remote_addr)
45        };
46
47        console_info!(status_tx, "Connecting to CouchDB at {}", url);
48
49        // Create CouchDB client using couch_rs
50        let client = if let (Some(user), Some(pass)) = (username.clone(), password.clone()) {
51            console_info!(status_tx, "Using basic auth (username: {})", user);
52            couch_rs::Client::new(&url, &user, &pass)
53                .context(format!("Failed to create CouchDB client for {}", url))?
54        } else {
55            couch_rs::Client::new_no_auth(&url)
56                .context(format!("Failed to create CouchDB client for {}", url))?
57        };
58
59        // Try to connect and get server info
60        let server_info = match client.check_status().await {
61            Ok(status) => {
62                console_info!(
63                    status_tx,
64                    "Connected to CouchDB (version: {})",
65                    &status.version
66                );
67                Some(serde_json::json!({
68                    "couchdb": "Welcome",
69                    "version": status.version,
70                    "vendor": status.vendor
71                }))
72            }
73            Err(e) => {
74                console_error!(status_tx, "Failed to get CouchDB server info: {}", e);
75                None
76            }
77        };
78
79        // Parse local address from URL
80        // For HTTP clients, we don't have a real local socket address
81        // Use a dummy address
82        let local_addr: SocketAddr = "127.0.0.1:0".parse().unwrap();
83
84        info!(
85            "CouchDB client {} connected to {} (local: {})",
86            client_id, url, local_addr
87        );
88
89        // Update client state
90        app_state
91            .update_client_status(client_id, ClientStatus::Connected)
92            .await;
93        let _ = status_tx.send(format!("[CLIENT] CouchDB client {} connected", client_id));
94        let _ = status_tx.send("__UPDATE_UI__".to_string());
95
96        // Clone for async task
97        let client_arc = Arc::new(tokio::sync::Mutex::new(client));
98        let client_for_connected = client_arc.clone();
99
100        // Command channel for injected actions (the dashboard's [ send ] row).
101        // Registered - and already being drained by its own task - BEFORE the
102        // connected-event LLM call, which a manual `*` rule can park for minutes: the
103        // operator must be able to reach the client while it waits. The couch_rs handle
104        // is already behind an Arc<Mutex<_>>, so the command task shares the very same
105        // connection the LLM path uses.
106        let command_rx =
107            crate::client::command_support::register_command_channel(&app_state, client_id).await;
108        let cmd_task = tokio::spawn(command_loop(
109            command_rx,
110            client_id,
111            client_arc.clone(),
112            app_state.clone(),
113            llm_client.clone(),
114            status_tx.clone(),
115        ));
116        app_state.register_client_task(client_id, cmd_task).await;
117
118        // Call LLM with couchdb_connected event
119        if let Some(instruction) = app_state.get_instruction_for_client(client_id).await {
120            let event = Event::new(
121                &COUCHDB_CLIENT_CONNECTED_EVENT,
122                serde_json::json!({
123                    "remote_addr": url,
124                    "server_info": server_info,
125                }),
126            );
127
128            match call_llm_for_client(
129                &llm_client,
130                &app_state,
131                client_id.to_string(),
132                &instruction,
133                &String::new(), // No memory yet for initial connection
134                Some(&event),
135                &crate::client::couchdb::actions::CouchDbClientProtocol::new(),
136                &status_tx,
137            )
138            .await
139            {
140                Ok(result) => {
141                    // Execute actions from LLM response
142                    // Use a queue to handle follow-up actions
143                    let mut action_queue: Vec<serde_json::Value> = result.actions;
144
145                    while let Some(action) = action_queue.pop() {
146                        match execute_couchdb_action(
147                            &action,
148                            client_id,
149                            &client_for_connected,
150                            0,
151                            &app_state,
152                            &llm_client,
153                            &status_tx,
154                            Dispatch::Inline,
155                        )
156                        .await
157                        {
158                            Ok(follow_up_actions) => {
159                                // Add follow-up actions to the front of the queue
160                                action_queue.extend(follow_up_actions.into_iter().rev());
161                            }
162                            Err(e) => {
163                                console_error!(
164                                    status_tx,
165                                    "Error executing action after connect: {}",
166                                    e
167                                );
168                            }
169                        }
170                    }
171                }
172                Err(e) => {
173                    console_error!(status_tx, "LLM error on couchdb_connected event: {}", e);
174                }
175            }
176        }
177
178        // A connect-event action may have disconnected the client; the handle must not
179        // outlive it or the dashboard offers [ send ] into a dead connection.
180        if matches!(
181            app_state.get_client(client_id).await.map(|c| c.status),
182            None | Some(ClientStatus::Disconnected)
183        ) {
184            app_state.remove_client_handle(client_id).await;
185        }
186
187        // CouchDB has no persistent read loop: operations are driven by actions. The
188        // command task spawned above is what keeps the client alive - it replaces the old
189        // 5s "is the client gone yet" poll, because the command channel closes the moment
190        // the client is removed.
191        Ok(local_addr)
192    }
193}
194
195/// Drain injected commands until the channel closes (client removed) or an injected
196/// `disconnect` ends the session.
197///
198/// `command_support::handle_stream_client_command` cannot serve this client: it writes
199/// `SendData` to a socket, and every CouchDB verb yields `ClientActionResult::Custom`
200/// that has to go through `couch_rs`. So the action goes through
201/// [`execute_couchdb_action`] - the same function the connected-event path uses - and the
202/// outcome is recorded and replied exactly the way the generic arm does it.
203async fn command_loop(
204    mut command_rx: tokio::sync::mpsc::Receiver<ClientCommand>,
205    client_id: ClientId,
206    client: Arc<tokio::sync::Mutex<couch_rs::Client>>,
207    app_state: Arc<AppState>,
208    llm_client: OllamaClient,
209    status_tx: mpsc::UnboundedSender<String>,
210) {
211    let protocol = crate::client::couchdb::actions::CouchDbClientProtocol::new();
212
213    while let Some(command) = command_rx.recv().await {
214        let action = command.action.clone();
215
216        // Validate through the protocol's own vocabulary first, so an unknown or
217        // misspelled action is reported as Rejected rather than silently ignored.
218        let outcome = match protocol.execute_action(action.clone()) {
219            Err(e) => Ok(ClientSendOutcome::Rejected {
220                error: e.to_string(),
221            }),
222            Ok(ClientActionResult::Custom { name, .. }) if name == "disconnect" => {
223                app_state
224                    .update_client_status(client_id, ClientStatus::Disconnected)
225                    .await;
226                Ok(ClientSendOutcome::Disconnected)
227            }
228            Ok(_) => {
229                // Awaited, not dispatched: the reported outcome describes an operation
230                // that has actually completed, and the couchdb_response_received event
231                // has already fired (and its follow-up actions run) by then.
232                let mut queue = vec![action.clone()];
233                let mut executed = 0usize;
234                let mut failure: Option<anyhow::Error> = None;
235                while let Some(next) = queue.pop() {
236                    match execute_couchdb_action(
237                        &next,
238                        client_id,
239                        &client,
240                        0,
241                        &app_state,
242                        &llm_client,
243                        &status_tx,
244                        Dispatch::Deferred,
245                    )
246                    .await
247                    {
248                        Ok(follow_ups) => {
249                            executed += 1;
250                            queue.extend(follow_ups.into_iter().rev());
251                        }
252                        Err(e) => {
253                            failure = Some(e);
254                            break;
255                        }
256                    }
257                }
258                match failure {
259                    Some(e) => Err(e),
260                    None => {
261                        let action_type = action
262                            .get("type")
263                            .and_then(|v| v.as_str())
264                            .unwrap_or("action");
265                        Ok(ClientSendOutcome::Executed {
266                            detail: if executed > 1 {
267                                format!(
268                                    "{action_type} executed ({} follow-up action(s) from the \
269                                     response event)",
270                                    executed - 1
271                                )
272                            } else {
273                                format!("{action_type} executed")
274                            },
275                        })
276                    }
277                }
278            }
279        };
280
281        let outcome_json = match &outcome {
282            Ok(outcome) => serde_json::to_value(outcome).unwrap_or(serde_json::Value::Null),
283            Err(e) => serde_json::json!({"error": e.to_string()}),
284        };
285        app_state
286            .record_access_log(
287                AccessLogOwner::Client(client_id.as_u32()),
288                protocol.protocol_name(),
289                None,
290                "injected_action",
291                action,
292                vec![outcome_json],
293            )
294            .await;
295
296        let disconnect = matches!(outcome, Ok(ClientSendOutcome::Disconnected));
297        if let Err(e) = &outcome {
298            error!("CouchDB client {} injected action failed: {}", client_id, e);
299            let _ = status_tx.send(format!(
300                "[WARN] Client {} injected action failed: {}",
301                client_id, e
302            ));
303        }
304        let _ = status_tx.send("__UPDATE_UI__".to_string());
305        crate::client::command_support::reply(command, outcome);
306
307        if disconnect {
308            break;
309        }
310    }
311
312    info!("CouchDB client {} command loop stopped", client_id);
313    app_state.remove_client_handle(client_id).await;
314    let _ = status_tx.send("__UPDATE_UI__".to_string());
315}
316
317/// Execute a CouchDB action from the LLM
318/// Whether the `couchdb_response_received` event's LLM call runs on the caller's
319/// task or on its own.
320/// How many LLM turns one action -> response-event -> action chain may take.
321///
322/// The cycle is real: `create_database` raises `couchdb_response_received`, whose answer may be
323/// another `create_database`. Executing that answer -- which this client did not used to do on
324/// the deferred path -- is what makes the recursion possible, so it is bounded rather than cut.
325const MAX_FOLLOWUP_DEPTH: u8 = 6;
326
327#[derive(Clone, Copy)]
328enum Dispatch {
329    /// Await the LLM call and hand its actions back, so the connected-event path
330    /// keeps draining follow-ups exactly as it always has.
331    Inline,
332    /// Raise the event from its own registered task and return no actions.
333    ///
334    /// Used by the injected-command loop. A dashboard-created client defaults to a
335    /// `*` -> manual rule, so that LLM call can park for up to 300s waiting for a
336    /// human, while the composer's SEND_TIMEOUT is 30s. Awaiting it would report a
337    /// command that in fact succeeded as a timeout, and head-of-line-block every
338    /// later command on the channel.
339    Deferred,
340}
341
342/// Run the actions a deferred event's answer produced, bounded by [`MAX_FOLLOWUP_DEPTH`].
343///
344/// Shared by the two deferred paths (`couchdb_response_received` and
345/// `couchdb_client_conflict`). Each action can itself raise a response event, so this is the
346/// recursive edge; hitting the bound reports on the status stream rather than stopping quietly.
347#[allow(clippy::too_many_arguments)]
348async fn run_followups(
349    actions: Vec<serde_json::Value>,
350    client_id: ClientId,
351    client: &Arc<tokio::sync::Mutex<couch_rs::Client>>,
352    depth: u8,
353    app_state: &Arc<AppState>,
354    llm_client: &OllamaClient,
355    status_tx: &mpsc::UnboundedSender<String>,
356) {
357    if actions.is_empty() {
358        return;
359    }
360    if depth >= MAX_FOLLOWUP_DEPTH {
361        warn!(
362            "CouchDB client {} stopped a follow-up chain at depth {}: {} action(s) not executed",
363            client_id,
364            depth,
365            actions.len()
366        );
367        let _ = status_tx.send(format!(
368            "[CLIENT] âš  couchdb client {} hit the follow-up depth limit ({}); {} action(s) were \
369             not executed",
370            client_id,
371            MAX_FOLLOWUP_DEPTH,
372            actions.len()
373        ));
374        return;
375    }
376
377    let mut queue = actions;
378    while let Some(action) = queue.pop() {
379        match execute_couchdb_action(
380            &action,
381            client_id,
382            client,
383            depth + 1,
384            app_state,
385            llm_client,
386            status_tx,
387            Dispatch::Deferred,
388        )
389        .await
390        {
391            Ok(more) => queue.extend(more.into_iter().rev()),
392            Err(e) => {
393                error!(
394                    "CouchDB client {} follow-up action failed: {}",
395                    client_id, e
396                );
397                let _ = status_tx.send(format!(
398                    "[CLIENT] couchdb client {client_id} follow-up action failed: {e}"
399                ));
400            }
401        }
402    }
403}
404
405/// Returns an explicitly boxed future rather than being an `async fn`.
406///
407/// Load-bearing: the chain is `execute_couchdb_action` -> `send_response_event` ->
408/// `run_followups` -> `execute_couchdb_action`, and async fns in a cycle cannot have their
409/// opaque return types inferred (E0391). Naming the type here breaks it at the definition;
410/// boxing at a call site does not, because the coercion still needs the callee's opaque type.
411///
412/// **Every arm copies the handle out of the mutex and releases it immediately**
413/// (`client.lock().await.clone()`), rather than holding the guard for the arm's body.
414/// `couch_rs::Client` is a cheap `Clone` over a reqwest client — `Database::new` clones one
415/// internally — so the copy costs nothing, and the guard was previously held across
416/// `send_response_event`, which on the `Dispatch::Inline` path **makes an LLM call**. A
417/// dashboard-created client defaults to a `*` -> manual routing rule, so that call can park
418/// for the full 300s intercept timeout with the CouchDB handle locked, and every injected
419/// `[ send ]` command blocks on `client.lock()` for the whole park — defeating the one thing
420/// the command channel exists to guarantee. It also serialised operations that reqwest is
421/// perfectly happy to run concurrently.
422#[allow(clippy::too_many_arguments)]
423fn execute_couchdb_action<'a>(
424    action: &'a serde_json::Value,
425    client_id: ClientId,
426    client: &'a Arc<tokio::sync::Mutex<couch_rs::Client>>,
427    depth: u8,
428    app_state: &'a Arc<AppState>,
429    llm_client: &'a OllamaClient,
430    status_tx: &'a mpsc::UnboundedSender<String>,
431    notify: Dispatch,
432) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<Vec<serde_json::Value>>> + Send + 'a>>
433{
434    Box::pin(async move {
435        let action_type = action
436            .get("type")
437            .and_then(|v| v.as_str())
438            .ok_or_else(|| anyhow::anyhow!("Missing action type"))?;
439
440        match action_type {
441            "create_database" => {
442                let db_name = action
443                    .get("database")
444                    .and_then(|v| v.as_str())
445                    .ok_or_else(|| anyhow::anyhow!("Missing database name"))?;
446
447                console_info!(status_tx, "Creating database: {}", db_name);
448
449                let couch = client.lock().await.clone();
450                let actions = match couch.make_db(db_name).await {
451                    Ok(_) => {
452                        console_info!(status_tx, "Database {} created successfully", db_name);
453                        send_response_event(
454                            client_id,
455                            "create_database",
456                            true,
457                            serde_json::json!({"database": db_name}),
458                            None,
459                            client,
460                            depth,
461                            app_state,
462                            llm_client,
463                            status_tx,
464                            notify,
465                        )
466                        .await
467                    }
468                    Err(e) => {
469                        console_error!(status_tx, "Failed to create database {}: {}", db_name, e);
470                        send_response_event(
471                            client_id,
472                            "create_database",
473                            false,
474                            serde_json::json!({}),
475                            Some(format!("{}", e)),
476                            client,
477                            depth,
478                            app_state,
479                            llm_client,
480                            status_tx,
481                            notify,
482                        )
483                        .await
484                    }
485                };
486                return Ok(actions);
487            }
488            "delete_database" => {
489                let db_name = action
490                    .get("database")
491                    .and_then(|v| v.as_str())
492                    .ok_or_else(|| anyhow::anyhow!("Missing database name"))?;
493
494                console_info!(status_tx, "Deleting database: {}", db_name);
495
496                let couch = client.lock().await.clone();
497                match couch.destroy_db(db_name).await {
498                    Ok(_) => {
499                        console_info!(status_tx, "Database {} deleted successfully", db_name);
500                        return Ok(send_response_event(
501                            client_id,
502                            "delete_database",
503                            true,
504                            serde_json::json!({"database": db_name}),
505                            None,
506                            client,
507                            depth,
508                            app_state,
509                            llm_client,
510                            status_tx,
511                            notify,
512                        )
513                        .await);
514                    }
515                    Err(e) => {
516                        console_error!(status_tx, "Failed to delete database {}: {}", db_name, e);
517                        return Ok(send_response_event(
518                            client_id,
519                            "delete_database",
520                            false,
521                            serde_json::json!({}),
522                            Some(format!("{}", e)),
523                            client,
524                            depth,
525                            app_state,
526                            llm_client,
527                            status_tx,
528                            notify,
529                        )
530                        .await);
531                    }
532                }
533            }
534            "list_databases" => {
535                console_info!(status_tx, "Listing all databases");
536
537                let couch = client.lock().await.clone();
538                match couch.list_dbs().await {
539                    Ok(dbs) => {
540                        console_info!(status_tx, "Found {} databases", dbs.len());
541                        return Ok(send_response_event(
542                            client_id,
543                            "list_databases",
544                            true,
545                            serde_json::json!({"databases": dbs}),
546                            None,
547                            client,
548                            depth,
549                            app_state,
550                            llm_client,
551                            status_tx,
552                            notify,
553                        )
554                        .await);
555                    }
556                    Err(e) => {
557                        console_error!(status_tx, "Failed to list databases: {}", e);
558                        return Ok(send_response_event(
559                            client_id,
560                            "list_databases",
561                            false,
562                            serde_json::json!({}),
563                            Some(format!("{}", e)),
564                            client,
565                            depth,
566                            app_state,
567                            llm_client,
568                            status_tx,
569                            notify,
570                        )
571                        .await);
572                    }
573                }
574            }
575            "create_document" => {
576                let db_name = action
577                    .get("database")
578                    .and_then(|v| v.as_str())
579                    .ok_or_else(|| anyhow::anyhow!("Missing database name"))?;
580
581                let doc_id = action.get("doc_id").and_then(|v| v.as_str());
582
583                let document = action
584                    .get("document")
585                    .ok_or_else(|| anyhow::anyhow!("Missing document"))?;
586
587                console_info!(status_tx, "Creating document in {}: {:?}", db_name, doc_id);
588
589                let couch = client.lock().await.clone();
590
591                // Use raw HTTP API via req()
592                let (path, method) = if let Some(id) = doc_id {
593                    // PUT /{db}/{docid} - create with specific ID
594                    (format!("/{}/{}", db_name, id), reqwest::Method::PUT)
595                } else {
596                    // POST /{db} - auto-generate ID
597                    (format!("/{}", db_name), reqwest::Method::POST)
598                };
599
600                let response = match couch
601                    .req(method.clone(), &path, None)
602                    .json(document)
603                    .send()
604                    .await
605                {
606                    Ok(resp) => resp,
607                    Err(e) => {
608                        console_error!(status_tx, "Failed to create document: {}", e);
609                        return Ok(send_response_event(
610                            client_id,
611                            "create_document",
612                            false,
613                            serde_json::json!({}),
614                            Some(format!("Request failed: {}", e)),
615                            client,
616                            depth,
617                            app_state,
618                            llm_client,
619                            status_tx,
620                            notify,
621                        )
622                        .await);
623                    }
624                };
625
626                let status = response.status();
627                match response.json::<serde_json::Value>().await {
628                    Ok(result) => {
629                        if status.is_success() {
630                            console_info!(
631                                status_tx,
632                                "Document created: {} (rev: {})",
633                                result
634                                    .get("id")
635                                    .and_then(|v| v.as_str())
636                                    .unwrap_or("unknown"),
637                                result
638                                    .get("rev")
639                                    .and_then(|v| v.as_str())
640                                    .unwrap_or("unknown")
641                            );
642                            return Ok(send_response_event(
643                                client_id,
644                                "create_document",
645                                true,
646                                result,
647                                None,
648                                client,
649                                depth,
650                                app_state,
651                                llm_client,
652                                status_tx,
653                                notify,
654                            )
655                            .await);
656                        } else {
657                            console_error!(
658                                status_tx,
659                                "Failed to create document: {} - {}",
660                                status,
661                                result
662                                    .get("reason")
663                                    .and_then(|v| v.as_str())
664                                    .unwrap_or("unknown error")
665                            );
666                            return Ok(send_response_event(
667                                client_id,
668                                "create_document",
669                                false,
670                                serde_json::json!({}),
671                                Some(format!(
672                                    "{}: {}",
673                                    result
674                                        .get("error")
675                                        .and_then(|v| v.as_str())
676                                        .unwrap_or("error"),
677                                    result
678                                        .get("reason")
679                                        .and_then(|v| v.as_str())
680                                        .unwrap_or("unknown")
681                                )),
682                                client,
683                                depth,
684                                app_state,
685                                llm_client,
686                                status_tx,
687                                notify,
688                            )
689                            .await);
690                        }
691                    }
692                    Err(e) => {
693                        console_error!(status_tx, "Failed to parse response: {}", e);
694                        return Ok(send_response_event(
695                            client_id,
696                            "create_document",
697                            false,
698                            serde_json::json!({}),
699                            Some(format!("Response parsing failed: {}", e)),
700                            client,
701                            depth,
702                            app_state,
703                            llm_client,
704                            status_tx,
705                            notify,
706                        )
707                        .await);
708                    }
709                }
710            }
711            "get_document" => {
712                let db_name = action
713                    .get("database")
714                    .and_then(|v| v.as_str())
715                    .ok_or_else(|| anyhow::anyhow!("Missing database name"))?;
716
717                let doc_id = action
718                    .get("doc_id")
719                    .and_then(|v| v.as_str())
720                    .ok_or_else(|| anyhow::anyhow!("Missing doc_id"))?;
721
722                console_info!(status_tx, "Getting document {}/{}", db_name, doc_id);
723
724                let couch = client.lock().await.clone();
725                let db = match couch.db(db_name).await {
726                    Ok(db) => db,
727                    Err(e) => {
728                        console_error!(status_tx, "Failed to get database {}: {}", db_name, e);
729                        return Ok(send_response_event(
730                            client_id,
731                            "get_document",
732                            false,
733                            serde_json::json!({}),
734                            Some(format!("Database not found: {}", e)),
735                            client,
736                            depth,
737                            app_state,
738                            llm_client,
739                            status_tx,
740                            notify,
741                        )
742                        .await);
743                    }
744                };
745
746                match db.get_raw(doc_id).await {
747                    Ok(doc) => {
748                        console_info!(status_tx, "Document retrieved: {}", doc_id);
749                        return Ok(send_response_event(
750                            client_id,
751                            "get_document",
752                            true,
753                            doc,
754                            None,
755                            client,
756                            depth,
757                            app_state,
758                            llm_client,
759                            status_tx,
760                            notify,
761                        )
762                        .await);
763                    }
764                    Err(e) => {
765                        console_error!(status_tx, "Failed to get document {}: {}", doc_id, e);
766                        return Ok(send_response_event(
767                            client_id,
768                            "get_document",
769                            false,
770                            serde_json::json!({}),
771                            Some(format!("{}", e)),
772                            client,
773                            depth,
774                            app_state,
775                            llm_client,
776                            status_tx,
777                            notify,
778                        )
779                        .await);
780                    }
781                }
782            }
783            "update_document" => {
784                let db_name = action
785                    .get("database")
786                    .and_then(|v| v.as_str())
787                    .ok_or_else(|| anyhow::anyhow!("Missing database name"))?;
788
789                let doc_id = action
790                    .get("doc_id")
791                    .and_then(|v| v.as_str())
792                    .ok_or_else(|| anyhow::anyhow!("Missing doc_id"))?;
793
794                let document = action
795                    .get("document")
796                    .ok_or_else(|| anyhow::anyhow!("Missing document"))?;
797
798                // Ensure document has _id
799                let mut doc = document.clone();
800                if let Some(obj) = doc.as_object_mut() {
801                    obj.insert("_id".to_string(), serde_json::json!(doc_id));
802                }
803
804                // Verify _rev is present (required for updates)
805                let rev = doc.get("_rev").and_then(|v| v.as_str()).ok_or_else(|| {
806                    anyhow::anyhow!("Missing _rev field in document (required for updates)")
807                })?;
808
809                console_info!(
810                    status_tx,
811                    "Updating document {}/{} (rev: {})",
812                    db_name,
813                    doc_id,
814                    rev
815                );
816
817                let couch = client.lock().await.clone();
818
819                // Use raw HTTP API via req()
820                // PUT /{db}/{docid} with document including _rev
821                let path = format!("/{}/{}", db_name, doc_id);
822
823                let response = match couch
824                    .req(reqwest::Method::PUT, &path, None)
825                    .json(&doc)
826                    .send()
827                    .await
828                {
829                    Ok(resp) => resp,
830                    Err(e) => {
831                        console_error!(status_tx, "Failed to update document: {}", e);
832                        return Ok(send_response_event(
833                            client_id,
834                            "update_document",
835                            false,
836                            serde_json::json!({}),
837                            Some(format!("Request failed: {}", e)),
838                            client,
839                            depth,
840                            app_state,
841                            llm_client,
842                            status_tx,
843                            notify,
844                        )
845                        .await);
846                    }
847                };
848
849                let status = response.status();
850                match response.json::<serde_json::Value>().await {
851                    Ok(result) => {
852                        if status.is_success() {
853                            console_info!(
854                                status_tx,
855                                "Document updated: {} (new rev: {})",
856                                result
857                                    .get("id")
858                                    .and_then(|v| v.as_str())
859                                    .unwrap_or("unknown"),
860                                result
861                                    .get("rev")
862                                    .and_then(|v| v.as_str())
863                                    .unwrap_or("unknown")
864                            );
865                            return Ok(send_response_event(
866                                client_id,
867                                "update_document",
868                                true,
869                                result,
870                                None,
871                                client,
872                                depth,
873                                app_state,
874                                llm_client,
875                                status_tx,
876                                notify,
877                            )
878                            .await);
879                        } else {
880                            // Check for conflict (409)
881                            if status.as_u16() == 409 {
882                                console_error!(
883                                    status_tx,
884                                    "Conflict updating document {}: revision mismatch",
885                                    doc_id
886                                );
887                                let conflict_actions = send_conflict_event(
888                                    client_id,
889                                    db_name,
890                                    doc_id,
891                                    Some(rev),
892                                    client,
893                                    depth,
894                                    app_state,
895                                    llm_client,
896                                    status_tx,
897                                    notify,
898                                )
899                                .await;
900                                if !conflict_actions.is_empty() {
901                                    return Ok(conflict_actions);
902                                }
903                            }
904
905                            console_error!(
906                                status_tx,
907                                "Failed to update document: {} - {}",
908                                status,
909                                result
910                                    .get("reason")
911                                    .and_then(|v| v.as_str())
912                                    .unwrap_or("unknown error")
913                            );
914                            return Ok(send_response_event(
915                                client_id,
916                                "update_document",
917                                false,
918                                serde_json::json!({}),
919                                Some(format!(
920                                    "{}: {}",
921                                    result
922                                        .get("error")
923                                        .and_then(|v| v.as_str())
924                                        .unwrap_or("error"),
925                                    result
926                                        .get("reason")
927                                        .and_then(|v| v.as_str())
928                                        .unwrap_or("unknown")
929                                )),
930                                client,
931                                depth,
932                                app_state,
933                                llm_client,
934                                status_tx,
935                                notify,
936                            )
937                            .await);
938                        }
939                    }
940                    Err(e) => {
941                        console_error!(status_tx, "Failed to parse response: {}", e);
942                        return Ok(send_response_event(
943                            client_id,
944                            "update_document",
945                            false,
946                            serde_json::json!({}),
947                            Some(format!("Response parsing failed: {}", e)),
948                            client,
949                            depth,
950                            app_state,
951                            llm_client,
952                            status_tx,
953                            notify,
954                        )
955                        .await);
956                    }
957                }
958            }
959            "delete_document" => {
960                let db_name = action
961                    .get("database")
962                    .and_then(|v| v.as_str())
963                    .ok_or_else(|| anyhow::anyhow!("Missing database name"))?;
964
965                let doc_id = action
966                    .get("doc_id")
967                    .and_then(|v| v.as_str())
968                    .ok_or_else(|| anyhow::anyhow!("Missing doc_id"))?;
969
970                let rev = action
971                    .get("rev")
972                    .and_then(|v| v.as_str())
973                    .ok_or_else(|| anyhow::anyhow!("Missing rev"))?;
974
975                console_info!(
976                    status_tx,
977                    "Deleting document {}/{} (rev: {})",
978                    db_name,
979                    doc_id,
980                    rev
981                );
982
983                let couch = client.lock().await.clone();
984
985                // Use raw HTTP API via req()
986                // DELETE /{db}/{docid}?rev={rev}
987                let path = format!("/{}/{}", db_name, doc_id);
988                let mut params = std::collections::HashMap::new();
989                params.insert("rev".to_string(), rev.to_string());
990
991                let response = match couch
992                    .req(reqwest::Method::DELETE, &path, Some(&params))
993                    .send()
994                    .await
995                {
996                    Ok(resp) => resp,
997                    Err(e) => {
998                        console_error!(status_tx, "Failed to delete document: {}", e);
999                        return Ok(send_response_event(
1000                            client_id,
1001                            "delete_document",
1002                            false,
1003                            serde_json::json!({}),
1004                            Some(format!("Request failed: {}", e)),
1005                            client,
1006                            depth,
1007                            app_state,
1008                            llm_client,
1009                            status_tx,
1010                            notify,
1011                        )
1012                        .await);
1013                    }
1014                };
1015
1016                let status = response.status();
1017                match response.json::<serde_json::Value>().await {
1018                    Ok(result) => {
1019                        if status.is_success() {
1020                            console_info!(
1021                                status_tx,
1022                                "Document deleted: {} (rev: {})",
1023                                result
1024                                    .get("id")
1025                                    .and_then(|v| v.as_str())
1026                                    .unwrap_or("unknown"),
1027                                result
1028                                    .get("rev")
1029                                    .and_then(|v| v.as_str())
1030                                    .unwrap_or("unknown")
1031                            );
1032                            return Ok(send_response_event(
1033                                client_id,
1034                                "delete_document",
1035                                true,
1036                                result,
1037                                None,
1038                                client,
1039                                depth,
1040                                app_state,
1041                                llm_client,
1042                                status_tx,
1043                                notify,
1044                            )
1045                            .await);
1046                        } else {
1047                            // Check for conflict (409)
1048                            if status.as_u16() == 409 {
1049                                console_error!(
1050                                    status_tx,
1051                                    "Conflict deleting document {}: revision mismatch",
1052                                    doc_id
1053                                );
1054                                let conflict_actions = send_conflict_event(
1055                                    client_id,
1056                                    db_name,
1057                                    doc_id,
1058                                    Some(rev),
1059                                    client,
1060                                    depth,
1061                                    app_state,
1062                                    llm_client,
1063                                    status_tx,
1064                                    notify,
1065                                )
1066                                .await;
1067                                if !conflict_actions.is_empty() {
1068                                    return Ok(conflict_actions);
1069                                }
1070                            }
1071
1072                            console_error!(
1073                                status_tx,
1074                                "Failed to delete document: {} - {}",
1075                                status,
1076                                result
1077                                    .get("reason")
1078                                    .and_then(|v| v.as_str())
1079                                    .unwrap_or("unknown error")
1080                            );
1081                            return Ok(send_response_event(
1082                                client_id,
1083                                "delete_document",
1084                                false,
1085                                serde_json::json!({}),
1086                                Some(format!(
1087                                    "{}: {}",
1088                                    result
1089                                        .get("error")
1090                                        .and_then(|v| v.as_str())
1091                                        .unwrap_or("error"),
1092                                    result
1093                                        .get("reason")
1094                                        .and_then(|v| v.as_str())
1095                                        .unwrap_or("unknown")
1096                                )),
1097                                client,
1098                                depth,
1099                                app_state,
1100                                llm_client,
1101                                status_tx,
1102                                notify,
1103                            )
1104                            .await);
1105                        }
1106                    }
1107                    Err(e) => {
1108                        console_error!(status_tx, "Failed to parse response: {}", e);
1109                        return Ok(send_response_event(
1110                            client_id,
1111                            "delete_document",
1112                            false,
1113                            serde_json::json!({}),
1114                            Some(format!("Response parsing failed: {}", e)),
1115                            client,
1116                            depth,
1117                            app_state,
1118                            llm_client,
1119                            status_tx,
1120                            notify,
1121                        )
1122                        .await);
1123                    }
1124                }
1125            }
1126            "bulk_docs" => {
1127                let db_name = action
1128                    .get("database")
1129                    .and_then(|v| v.as_str())
1130                    .ok_or_else(|| anyhow::anyhow!("Missing database name"))?;
1131
1132                let docs = action
1133                    .get("docs")
1134                    .and_then(|v| v.as_array())
1135                    .ok_or_else(|| anyhow::anyhow!("Missing or invalid docs array"))?;
1136
1137                console_info!(
1138                    status_tx,
1139                    "Bulk docs: {} documents in {}",
1140                    docs.len(),
1141                    db_name
1142                );
1143
1144                let couch = client.lock().await.clone();
1145
1146                // Use raw HTTP API via req()
1147                // POST /{db}/_bulk_docs with {"docs": [...]}
1148                let path = format!("/{}/_bulk_docs", db_name);
1149                let body = serde_json::json!({
1150                    "docs": docs
1151                });
1152
1153                let response = match couch
1154                    .req(reqwest::Method::POST, &path, None)
1155                    .json(&body)
1156                    .send()
1157                    .await
1158                {
1159                    Ok(resp) => resp,
1160                    Err(e) => {
1161                        console_error!(status_tx, "Failed to perform bulk docs: {}", e);
1162                        return Ok(send_response_event(
1163                            client_id,
1164                            "bulk_docs",
1165                            false,
1166                            serde_json::json!({}),
1167                            Some(format!("Request failed: {}", e)),
1168                            client,
1169                            depth,
1170                            app_state,
1171                            llm_client,
1172                            status_tx,
1173                            notify,
1174                        )
1175                        .await);
1176                    }
1177                };
1178
1179                let status = response.status();
1180                match response.json::<serde_json::Value>().await {
1181                    Ok(results) => {
1182                        if status.is_success() {
1183                            // Results is an array of {ok, id, rev} or {error, reason}
1184                            let count = if let Some(arr) = results.as_array() {
1185                                arr.len()
1186                            } else {
1187                                0
1188                            };
1189                            console_info!(status_tx, "Bulk docs completed: {} results", count);
1190                            return Ok(send_response_event(
1191                                client_id,
1192                                "bulk_docs",
1193                                true,
1194                                results,
1195                                None,
1196                                client,
1197                                depth,
1198                                app_state,
1199                                llm_client,
1200                                status_tx,
1201                                notify,
1202                            )
1203                            .await);
1204                        } else {
1205                            console_error!(
1206                                status_tx,
1207                                "Failed to perform bulk docs: {} - {}",
1208                                status,
1209                                results
1210                                    .get("reason")
1211                                    .and_then(|v| v.as_str())
1212                                    .unwrap_or("unknown error")
1213                            );
1214                            return Ok(send_response_event(
1215                                client_id,
1216                                "bulk_docs",
1217                                false,
1218                                serde_json::json!({}),
1219                                Some(format!(
1220                                    "{}: {}",
1221                                    results
1222                                        .get("error")
1223                                        .and_then(|v| v.as_str())
1224                                        .unwrap_or("error"),
1225                                    results
1226                                        .get("reason")
1227                                        .and_then(|v| v.as_str())
1228                                        .unwrap_or("unknown")
1229                                )),
1230                                client,
1231                                depth,
1232                                app_state,
1233                                llm_client,
1234                                status_tx,
1235                                notify,
1236                            )
1237                            .await);
1238                        }
1239                    }
1240                    Err(e) => {
1241                        console_error!(status_tx, "Failed to parse response: {}", e);
1242                        return Ok(send_response_event(
1243                            client_id,
1244                            "bulk_docs",
1245                            false,
1246                            serde_json::json!({}),
1247                            Some(format!("Response parsing failed: {}", e)),
1248                            client,
1249                            depth,
1250                            app_state,
1251                            llm_client,
1252                            status_tx,
1253                            notify,
1254                        )
1255                        .await);
1256                    }
1257                }
1258            }
1259            "list_documents" => {
1260                let db_name = action
1261                    .get("database")
1262                    .and_then(|v| v.as_str())
1263                    .ok_or_else(|| anyhow::anyhow!("Missing database name"))?;
1264
1265                let _include_docs = action
1266                    .get("include_docs")
1267                    .and_then(|v| v.as_bool())
1268                    .unwrap_or(false);
1269
1270                console_info!(status_tx, "Listing documents in {}", db_name);
1271
1272                let couch = client.lock().await.clone();
1273                let db = match couch.db(db_name).await {
1274                    Ok(db) => db,
1275                    Err(e) => {
1276                        console_error!(status_tx, "Failed to get database {}: {}", db_name, e);
1277                        return Ok(send_response_event(
1278                            client_id,
1279                            "list_documents",
1280                            false,
1281                            serde_json::json!({}),
1282                            Some(format!("Database not found: {}", e)),
1283                            client,
1284                            depth,
1285                            app_state,
1286                            llm_client,
1287                            status_tx,
1288                            notify,
1289                        )
1290                        .await);
1291                    }
1292                };
1293
1294                match db.get_all_raw().await {
1295                    Ok(all_docs) => {
1296                        console_info!(status_tx, "Found {} documents", all_docs.total_rows);
1297                        // Convert DocumentCollection to JSON manually
1298                        let docs_json = serde_json::json!({
1299                            "total_rows": all_docs.total_rows,
1300                            "offset": all_docs.offset,
1301                            "rows": all_docs.rows
1302                        });
1303                        return Ok(send_response_event(
1304                            client_id,
1305                            "list_documents",
1306                            true,
1307                            docs_json,
1308                            None,
1309                            client,
1310                            depth,
1311                            app_state,
1312                            llm_client,
1313                            status_tx,
1314                            notify,
1315                        )
1316                        .await);
1317                    }
1318                    Err(e) => {
1319                        console_error!(status_tx, "Failed to list documents: {}", e);
1320                        return Ok(send_response_event(
1321                            client_id,
1322                            "list_documents",
1323                            false,
1324                            serde_json::json!({}),
1325                            Some(format!("{}", e)),
1326                            client,
1327                            depth,
1328                            app_state,
1329                            llm_client,
1330                            status_tx,
1331                            notify,
1332                        )
1333                        .await);
1334                    }
1335                }
1336            }
1337            "query_view" => {
1338                console_info!(
1339                    status_tx,
1340                    "View queries not yet fully implemented in couch_rs"
1341                );
1342                return Ok(send_response_event(
1343                    client_id,
1344                    "query_view",
1345                    false,
1346                    serde_json::json!({}),
1347                    Some("View queries not yet implemented".to_string()),
1348                    client,
1349                    depth,
1350                    app_state,
1351                    llm_client,
1352                    status_tx,
1353                    notify,
1354                )
1355                .await);
1356            }
1357            "watch_changes" => {
1358                // Long-poll `/{db}/_changes` in its own registered task and raise
1359                // `couchdb_change_detected` per change.
1360                //
1361                // This verb used to answer "Changes feed not yet implemented", which meant
1362                // COUCHDB_CLIENT_CHANGE_DETECTED_EVENT -- advertised in get_event_types(), so
1363                // the model could be told it exists and write a handler for it -- could never
1364                // fire. Watching a database for changes is the one thing CouchDB is chosen
1365                // for over a plain document store.
1366                let database = action
1367                    .get("database")
1368                    .and_then(|v| v.as_str())
1369                    .unwrap_or_default()
1370                    .to_string();
1371                if database.is_empty() {
1372                    return Ok(send_response_event(
1373                        client_id,
1374                        "watch_changes",
1375                        false,
1376                        serde_json::json!({}),
1377                        Some("watch_changes needs a database".to_string()),
1378                        client,
1379                        depth,
1380                        app_state,
1381                        llm_client,
1382                        status_tx,
1383                        notify,
1384                    )
1385                    .await);
1386                }
1387
1388                let base_url = app_state
1389                    .with_client_mut(client_id, |c| {
1390                        c.get_protocol_field("remote_addr")
1391                            .and_then(|v| v.as_str())
1392                            .map(|s| s.to_string())
1393                    })
1394                    .await
1395                    .flatten()
1396                    .unwrap_or_default();
1397
1398                let watch_client = client.clone();
1399                let watch_state = app_state.clone();
1400                let watch_llm = llm_client.clone();
1401                let watch_tx = status_tx.clone();
1402                let db = database.clone();
1403                let handle = tokio::spawn(async move {
1404                    watch_changes_feed(
1405                        client_id,
1406                        base_url,
1407                        db,
1408                        watch_client,
1409                        watch_state,
1410                        watch_llm,
1411                        watch_tx,
1412                    )
1413                    .await;
1414                });
1415                app_state.register_client_task(client_id, handle).await;
1416
1417                return Ok(send_response_event(
1418                    client_id,
1419                    "watch_changes",
1420                    true,
1421                    serde_json::json!({ "database": database, "watching": true }),
1422                    None,
1423                    client,
1424                    depth,
1425                    app_state,
1426                    llm_client,
1427                    status_tx,
1428                    notify,
1429                )
1430                .await);
1431            }
1432            "disconnect" => {
1433                console_info!(status_tx, "Disconnecting CouchDB client {}", client_id);
1434                app_state
1435                    .update_client_status(client_id, ClientStatus::Disconnected)
1436                    .await;
1437                return Ok(Vec::new());
1438            }
1439            "wait_for_more" => {
1440                // No action needed - just acknowledge
1441                return Ok(Vec::new());
1442            }
1443            _ => {
1444                console_error!(status_tx, "Unknown action type: {}", action_type);
1445                return Ok(Vec::new());
1446            }
1447        }
1448    })
1449}
1450
1451/// Send response event to LLM
1452#[allow(clippy::too_many_arguments)]
1453async fn send_response_event(
1454    client_id: ClientId,
1455    operation: &str,
1456    success: bool,
1457    data: serde_json::Value,
1458    error: Option<String>,
1459    client: &Arc<tokio::sync::Mutex<couch_rs::Client>>,
1460    depth: u8,
1461    app_state: &Arc<AppState>,
1462    llm_client: &OllamaClient,
1463    status_tx: &mpsc::UnboundedSender<String>,
1464    notify: Dispatch,
1465) -> Vec<serde_json::Value> {
1466    if let Some(instruction) = app_state.get_instruction_for_client(client_id).await {
1467        let memory = app_state
1468            .get_memory_for_client(client_id)
1469            .await
1470            .unwrap_or_default();
1471
1472        let mut event_data = serde_json::json!({
1473            "operation": operation,
1474            "success": success,
1475            "data": data,
1476        });
1477
1478        if let Some(err) = error {
1479            event_data["error"] = serde_json::json!(err);
1480        }
1481
1482        let event = Event::new(&COUCHDB_CLIENT_RESPONSE_RECEIVED_EVENT, event_data);
1483
1484        // Deferred: hand the event to its own registered task and answer the caller
1485        // now. The command loop owes the dashboard a reply, and this LLM call may be
1486        // parked for a human; see `Dispatch::Deferred`.
1487        if let Dispatch::Deferred = notify {
1488            let llm_client = llm_client.clone();
1489            let app_state_task = app_state.clone();
1490            let status_tx = status_tx.clone();
1491            let client_task = client.clone();
1492            let handle = tokio::spawn(async move {
1493                match call_llm_for_client(
1494                    &llm_client,
1495                    &app_state_task,
1496                    client_id.to_string(),
1497                    &instruction,
1498                    &memory,
1499                    Some(&event),
1500                    &crate::client::couchdb::actions::CouchDbClientProtocol::new(),
1501                    &status_tx,
1502                )
1503                .await
1504                {
1505                    Ok(result) => {
1506                        if let Some(new_memory) = result.memory_updates {
1507                            app_state_task
1508                                .set_memory_for_client(client_id, new_memory)
1509                                .await;
1510                        }
1511                        // These used to be counted into a log line and dropped, on the
1512                        // stated grounds that "only the client's own loop owns the CouchDB
1513                        // handle". That was not true: the handle is an
1514                        // `Arc<tokio::sync::Mutex<couch_rs::Client>>`, shared by
1515                        // construction, and `execute_couchdb_action` already takes it by
1516                        // reference. The inline sibling of this branch returns its actions
1517                        // and they run, so the deferred path was the odd one out -- and it
1518                        // is the path every dashboard-injected command takes.
1519                        run_followups(
1520                            result.actions,
1521                            client_id,
1522                            &client_task,
1523                            depth,
1524                            &app_state_task,
1525                            &llm_client,
1526                            &status_tx,
1527                        )
1528                        .await;
1529                    }
1530                    Err(e) => error!("LLM error on deferred response event: {}", e),
1531                }
1532            });
1533            app_state.register_client_task(client_id, handle).await;
1534            return Vec::new();
1535        }
1536
1537        match call_llm_for_client(
1538            llm_client,
1539            app_state,
1540            client_id.to_string(),
1541            &instruction,
1542            &memory,
1543            Some(&event),
1544            &crate::client::couchdb::actions::CouchDbClientProtocol::new(),
1545            status_tx,
1546        )
1547        .await
1548        {
1549            Ok(result) => {
1550                // Update memory if provided
1551                if let Some(new_memory) = result.memory_updates {
1552                    app_state.set_memory_for_client(client_id, new_memory).await;
1553                }
1554
1555                // Return actions to be executed by caller
1556                return result.actions;
1557            }
1558            Err(e) => {
1559                error!("LLM error on response event: {}", e);
1560            }
1561        }
1562    }
1563    Vec::new()
1564}
1565
1566/// Send conflict event to LLM when document revision mismatch occurs
1567#[allow(clippy::too_many_arguments)]
1568async fn send_conflict_event(
1569    client_id: ClientId,
1570    database: &str,
1571    doc_id: &str,
1572    expected_rev: Option<&str>,
1573    client: &Arc<tokio::sync::Mutex<couch_rs::Client>>,
1574    depth: u8,
1575    app_state: &Arc<AppState>,
1576    llm_client: &OllamaClient,
1577    status_tx: &mpsc::UnboundedSender<String>,
1578    notify: Dispatch,
1579) -> Vec<serde_json::Value> {
1580    use crate::client::couchdb::actions::COUCHDB_CLIENT_CONFLICT_EVENT;
1581
1582    if let Some(instruction) = app_state.get_instruction_for_client(client_id).await {
1583        let memory = app_state
1584            .get_memory_for_client(client_id)
1585            .await
1586            .unwrap_or_default();
1587
1588        let event = Event::new(
1589            &COUCHDB_CLIENT_CONFLICT_EVENT,
1590            serde_json::json!({
1591                "database": database,
1592                "doc_id": doc_id,
1593                "expected_rev": expected_rev,
1594            }),
1595        );
1596
1597        // Deferred: same reasoning as in `send_response_event` -- the command loop
1598        // owes the dashboard a reply and must not wait on a parked LLM call.
1599        if let Dispatch::Deferred = notify {
1600            let llm_client = llm_client.clone();
1601            let app_state_task = app_state.clone();
1602            let status_tx = status_tx.clone();
1603            let client_task = client.clone();
1604            let handle = tokio::spawn(async move {
1605                match call_llm_for_client(
1606                    &llm_client,
1607                    &app_state_task,
1608                    client_id.to_string(),
1609                    &instruction,
1610                    &memory,
1611                    Some(&event),
1612                    &crate::client::couchdb::actions::CouchDbClientProtocol::new(),
1613                    &status_tx,
1614                )
1615                .await
1616                {
1617                    Ok(result) => {
1618                        if let Some(new_memory) = result.memory_updates {
1619                            app_state_task
1620                                .set_memory_for_client(client_id, new_memory)
1621                                .await;
1622                        }
1623                        // A 409 is precisely the event whose answer must run: the model has
1624                        // just been told a write lost a revision race and asked what to do
1625                        // about it. This arm did not exist.
1626                        run_followups(
1627                            result.actions,
1628                            client_id,
1629                            &client_task,
1630                            depth,
1631                            &app_state_task,
1632                            &llm_client,
1633                            &status_tx,
1634                        )
1635                        .await;
1636                    }
1637                    Err(e) => error!("LLM error on deferred conflict event: {}", e),
1638                }
1639            });
1640            app_state.register_client_task(client_id, handle).await;
1641            return Vec::new();
1642        }
1643
1644        // The model's answer to a conflict is returned to the caller and queued like
1645        // any other follow-up. It used to be discarded with `let _ =`, so a model
1646        // resolving a 409 was silently ignored.
1647        match call_llm_for_client(
1648            llm_client,
1649            app_state,
1650            client_id.to_string(),
1651            &instruction,
1652            &memory,
1653            Some(&event),
1654            &crate::client::couchdb::actions::CouchDbClientProtocol::new(),
1655            status_tx,
1656        )
1657        .await
1658        {
1659            Ok(result) => {
1660                if let Some(new_memory) = result.memory_updates {
1661                    app_state.set_memory_for_client(client_id, new_memory).await;
1662                }
1663                return result.actions;
1664            }
1665            Err(e) => error!("LLM error on conflict event: {}", e),
1666        }
1667    }
1668    Vec::new()
1669}
1670
1671/// Long-poll a database's `_changes` feed, raising `couchdb_change_detected` per change.
1672///
1673/// Long-poll rather than `feed=continuous`: each request returns a complete JSON body, so
1674/// there is no partial-line framing to get wrong, and the loop stops cleanly when the
1675/// client goes away. `since` advances to the sequence the server reports, so a change is
1676/// reported once.
1677///
1678/// The answer to each `couchdb_change_detected` **is** executed, bounded by
1679/// [`MAX_FOLLOWUP_DEPTH`].
1680///
1681/// This used to say it raised no actions of its own because "the event's answer is handled by
1682/// the normal event path". Nothing handled it: the call was an `if let Err(..)` with no success
1683/// arm, so a client told to watch a database and react to changes watched them and did nothing.
1684/// Each change starts its own bounded chain, which is the same contract every other event on
1685/// this client has.
1686#[allow(clippy::too_many_arguments)]
1687async fn watch_changes_feed(
1688    client_id: ClientId,
1689    base_url: String,
1690    database: String,
1691    client: Arc<tokio::sync::Mutex<couch_rs::Client>>,
1692    app_state: Arc<AppState>,
1693    llm_client: OllamaClient,
1694    status_tx: mpsc::UnboundedSender<String>,
1695) {
1696    // Built through the shared constructor so a literal-IP CouchDB host skips the system
1697    // resolver; see `crate::llm::ollama_client::client_for_endpoint`.
1698    let http = crate::llm::ollama_client::client_for_endpoint(&base_url);
1699    let mut since = "now".to_string();
1700    loop {
1701        if app_state.get_client(client_id).await.is_none() {
1702            return;
1703        }
1704        let url = format!(
1705            "{}/{}/_changes?feed=longpoll&since={}&timeout=30000",
1706            base_url.trim_end_matches('/'),
1707            database,
1708            since
1709        );
1710        let body: serde_json::Value = match http.get(&url).send().await {
1711            Ok(r) => match r.json().await {
1712                Ok(v) => v,
1713                Err(e) => {
1714                    tracing::warn!(
1715                        "CouchDB client {} changes feed decode failed: {}",
1716                        client_id,
1717                        e
1718                    );
1719                    tokio::time::sleep(std::time::Duration::from_secs(2)).await;
1720                    continue;
1721                }
1722            },
1723            Err(e) => {
1724                tracing::warn!(
1725                    "CouchDB client {} changes feed request failed: {}",
1726                    client_id,
1727                    e
1728                );
1729                tokio::time::sleep(std::time::Duration::from_secs(2)).await;
1730                continue;
1731            }
1732        };
1733        if let Some(seq) = body.get("last_seq") {
1734            since = seq
1735                .as_str()
1736                .map(|s| s.to_string())
1737                .unwrap_or_else(|| seq.to_string());
1738        }
1739        let Some(results) = body.get("results").and_then(|r| r.as_array()) else {
1740            continue;
1741        };
1742        for change in results {
1743            let Some(instruction) = app_state.get_instruction_for_client(client_id).await else {
1744                continue;
1745            };
1746            let event = Event::new(
1747                &crate::client::couchdb::actions::COUCHDB_CLIENT_CHANGE_DETECTED_EVENT,
1748                serde_json::json!({
1749                    "database": database,
1750                    "doc_id": change.get("id").and_then(|v| v.as_str()).unwrap_or_default(),
1751                    "seq": change.get("seq"),
1752                    "deleted": change.get("deleted").and_then(|v| v.as_bool()).unwrap_or(false),
1753                }),
1754            );
1755            let memory = app_state
1756                .get_memory_for_client(client_id)
1757                .await
1758                .unwrap_or_default();
1759            match call_llm_for_client(
1760                &llm_client,
1761                &app_state,
1762                client_id.to_string(),
1763                &instruction,
1764                &memory,
1765                Some(&event),
1766                &crate::client::couchdb::actions::CouchDbClientProtocol::new(),
1767                &status_tx,
1768            )
1769            .await
1770            {
1771                Ok(result) => {
1772                    if let Some(new_memory) = result.memory_updates {
1773                        app_state.set_memory_for_client(client_id, new_memory).await;
1774                    }
1775                    run_followups(
1776                        result.actions,
1777                        client_id,
1778                        &client,
1779                        0,
1780                        &app_state,
1781                        &llm_client,
1782                        &status_tx,
1783                    )
1784                    .await;
1785                }
1786                Err(e) => error!(
1787                    "CouchDB client {} LLM error on couchdb_change_detected: {}",
1788                    client_id, e
1789                ),
1790            }
1791        }
1792    }
1793}