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(¶ms))
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}