Skip to main content

netget/client/etcd/
mod.rs

1//! etcd client implementation
2pub mod actions;
3
4pub use actions::EtcdClientProtocol;
5
6use anyhow::{Context, Result};
7use std::net::SocketAddr;
8use std::sync::Arc;
9use tokio::sync::{mpsc, Mutex};
10use tracing::{debug, error, info, warn};
11
12use crate::client::etcd::actions::{
13    ETCD_CLIENT_CONNECTED_EVENT, ETCD_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::ollama_client::OllamaClient;
18use crate::protocol::Event;
19use crate::state::app_state::AppState;
20use crate::state::client_handles::{ClientCommand, ClientSendOutcome};
21use crate::state::{AccessLogOwner, ClientId, ClientStatus};
22
23/// The live etcd session, shared between the connected-event handler and the
24/// injected-command loop.
25///
26/// Holding the connected `etcd_client::Client` rather than dropping it is what makes
27/// injected actions possible at all: the alternative would be a command loop that
28/// re-dials etcd for every action, which is a second connection path with its own
29/// failure modes and no relationship to the session the dashboard says is "connected".
30type SharedEtcd = Arc<Mutex<etcd_client::Client>>;
31
32/// One completed etcd operation. `event_data` is the `etcd_response_received` payload;
33/// `detail` is the human-readable line the dashboard shows for an injected action.
34struct EtcdOutcome {
35    event_data: serde_json::Value,
36    detail: String,
37}
38
39/// What one executed action did. Shared vocabulary between the connected-event handler
40/// and the injected-command loop.
41enum Applied {
42    /// The action ran; `detail` says what it did.
43    Ran(String),
44    /// The action asked to end the session.
45    Disconnect,
46}
47
48/// etcd client that connects to remote etcd servers
49pub struct EtcdClient;
50
51impl EtcdClient {
52    /// Connect to an etcd server with integrated LLM actions
53    pub async fn connect_with_llm_actions(
54        remote_addr: String,
55        llm_client: OllamaClient,
56        app_state: Arc<AppState>,
57        status_tx: mpsc::UnboundedSender<String>,
58        client_id: ClientId,
59    ) -> Result<SocketAddr> {
60        info!("etcd client {} connecting to {}", client_id, remote_addr);
61
62        // Parse endpoint (etcd-client expects a Vec of endpoints)
63        let endpoints = vec![remote_addr.clone()];
64
65        // Connect to etcd using etcd-client. The handle is kept for the life of the
66        // client so every later operation runs on this session.
67        let etcd: SharedEtcd = Arc::new(Mutex::new(
68            etcd_client::Client::connect(&endpoints, None)
69                .await
70                .context("Failed to connect to etcd server")?,
71        ));
72
73        info!(
74            "etcd client {} connected successfully to {}",
75            client_id, remote_addr
76        );
77
78        // Store client state
79        app_state
80            .with_client_mut(client_id, |client| {
81                client.set_protocol_field("etcd_connected".to_string(), serde_json::json!(true));
82                client.set_protocol_field("endpoints".to_string(), serde_json::json!(endpoints));
83            })
84            .await;
85
86        // Update status
87        app_state
88            .update_client_status(client_id, ClientStatus::Connected)
89            .await;
90        let _ = status_tx.send(format!(
91            "[CLIENT] etcd client {} connected to {}",
92            client_id, remote_addr
93        ));
94        let _ = status_tx.send("__UPDATE_UI__".to_string());
95
96        // Command channel for injected actions (the dashboard's [ send ] / composer).
97        // Registered BEFORE the connected-event LLM call below, which a manual `*` routing
98        // rule can park for minutes - the operator must be able to reach etcd while it
99        // waits.
100        //
101        // This task also replaces the old "poll get_client() every 5s" idle task:
102        // `remove_client` drops the command sender, so `recv()` returns `None` the moment
103        // the client goes away.
104        let command_rx =
105            crate::client::command_support::register_command_channel(&app_state, client_id).await;
106        let cmd_task = tokio::spawn(Self::command_loop(
107            command_rx,
108            client_id,
109            etcd.clone(),
110            app_state.clone(),
111            llm_client.clone(),
112            status_tx.clone(),
113        ));
114        app_state.register_client_task(client_id, cmd_task).await;
115
116        // Send connected event to LLM
117        let connected_event = Event::new(
118            &ETCD_CLIENT_CONNECTED_EVENT,
119            serde_json::json!({
120                "remote_addr": remote_addr,
121            }),
122        );
123
124        // Call LLM with connected event
125        let protocol = Arc::new(EtcdClientProtocol::new());
126        let instruction = app_state
127            .with_client_mut(client_id, |client| client.instruction.clone())
128            .await
129            .unwrap_or_default();
130        let memory = app_state
131            .with_client_mut(client_id, |client| {
132                client
133                    .get_protocol_field("memory")
134                    .and_then(|v| v.as_str())
135                    .map(|s| s.to_string())
136            })
137            .await
138            .flatten()
139            .unwrap_or_default();
140
141        match call_llm_for_client(
142            &llm_client,
143            &app_state,
144            client_id.to_string(),
145            &instruction,
146            &memory,
147            Some(&connected_event),
148            protocol.as_ref(),
149            &status_tx,
150        )
151        .await
152        {
153            Ok(result) => {
154                if let Some(mem) = result.memory_updates {
155                    app_state
156                        .with_client_mut(client_id, |client| {
157                            client.set_protocol_field("memory".to_string(), serde_json::json!(mem));
158                        })
159                        .await;
160                }
161                debug!(
162                    "etcd client {} LLM generated {} actions on connect",
163                    client_id,
164                    result.actions.len()
165                );
166
167                // Execute them. Until this existed the connect handler counted the
168                // actions and threw them away, so an instruction like "PUT /a = b on
169                // connect" silently did nothing.
170                for action in result.actions {
171                    let executed = match protocol.execute_action(action) {
172                        Ok(executed) => executed,
173                        Err(e) => {
174                            error!("etcd client {} rejected action: {}", client_id, e);
175                            continue;
176                        }
177                    };
178                    match Self::apply_action(
179                        executed,
180                        client_id,
181                        &etcd,
182                        &app_state,
183                        &llm_client,
184                        &status_tx,
185                        0,
186                    )
187                    .await
188                    {
189                        Ok(Applied::Ran(detail)) => info!("etcd client {}: {}", client_id, detail),
190                        Ok(Applied::Disconnect) => break,
191                        Err(e) => error!("etcd client {} action failed: {}", client_id, e),
192                    }
193                }
194            }
195            Err(e) => {
196                error!(
197                    "etcd client {} LLM call failed on connect: {}",
198                    client_id, e
199                );
200            }
201        }
202
203        // Return a dummy local address (etcd client is connection-based but doesn't expose local addr)
204        Ok("0.0.0.0:0".parse().unwrap())
205    }
206
207    /// Drain injected commands until the channel closes (the client was removed) or an
208    /// injected `disconnect` ends the session.
209    ///
210    /// `command_support::handle_stream_client_command` cannot serve this client: there is
211    /// no write half NetGet owns - `etcd-client` holds the HTTP/2 connection - and every
212    /// etcd verb yields `ClientActionResult::Custom`. So the action goes through
213    /// [`Self::apply_action`], the same function the connected-event path uses.
214    async fn command_loop(
215        mut command_rx: mpsc::Receiver<ClientCommand>,
216        client_id: ClientId,
217        etcd: SharedEtcd,
218        app_state: Arc<AppState>,
219        llm_client: OllamaClient,
220        status_tx: mpsc::UnboundedSender<String>,
221    ) {
222        use crate::llm::actions::protocol_trait::Protocol;
223
224        let protocol = EtcdClientProtocol::new();
225
226        while let Some(command) = command_rx.recv().await {
227            let action = command.action.clone();
228            let outcome = match protocol.execute_action(action.clone()) {
229                Err(e) => Ok(ClientSendOutcome::Rejected {
230                    error: e.to_string(),
231                }),
232                Ok(executed) => match Self::apply_action(
233                    executed,
234                    client_id,
235                    &etcd,
236                    &app_state,
237                    &llm_client,
238                    &status_tx,
239                    0,
240                )
241                .await
242                {
243                    // Never `Sent`: `etcd-client` owns the gRPC connection and never
244                    // reports how many bytes a request serialised to, so a byte count
245                    // here would be invented. `Executed` carries what etcd actually
246                    // did instead - revision, key count, keys deleted.
247                    Ok(Applied::Ran(detail)) => Ok(ClientSendOutcome::Executed { detail }),
248                    Ok(Applied::Disconnect) => Ok(ClientSendOutcome::Disconnected),
249                    Err(e) => Err(e),
250                },
251            };
252
253            let outcome_json = match &outcome {
254                Ok(outcome) => serde_json::to_value(outcome).unwrap_or(serde_json::Value::Null),
255                Err(e) => serde_json::json!({"error": e.to_string()}),
256            };
257            app_state
258                .record_access_log(
259                    AccessLogOwner::Client(client_id.as_u32()),
260                    protocol.protocol_name(),
261                    None,
262                    "injected_action",
263                    action,
264                    vec![outcome_json],
265                )
266                .await;
267
268            let disconnect = matches!(outcome, Ok(ClientSendOutcome::Disconnected));
269            if let Err(e) = &outcome {
270                error!("etcd client {} injected action failed: {}", client_id, e);
271                let _ = status_tx.send(format!(
272                    "[WARN] Client {} injected action failed: {}",
273                    client_id, e
274                ));
275            }
276            let _ = status_tx.send("__UPDATE_UI__".to_string());
277            crate::client::command_support::reply(command, outcome);
278
279            if disconnect {
280                app_state
281                    .update_client_status(client_id, ClientStatus::Disconnected)
282                    .await;
283                break;
284            }
285        }
286
287        // Nothing can be injected any more: stop the dashboard offering [ send ].
288        app_state.remove_client_handle(client_id).await;
289        let _ = status_tx.send("__UPDATE_UI__".to_string());
290        info!("etcd client {} command loop ended", client_id);
291    }
292
293    /// Run one executed action against the live etcd session.
294    ///
295    /// The etcd round-trip is awaited - so the reported detail describes an operation
296    /// that really happened - while the `etcd_response_received` LLM call it triggers
297    /// runs in its own registered task. That split matters: a client whose events are
298    /// routed to a manual handler would otherwise park the command loop for the length
299    /// of a human's think time.
300    /// How many operations deep this client keeps following the model's answers. Each
301    /// operation reports its result, and that report can ask for another operation;
302    /// without a bound, a model that answers `etcd_get` with `etcd_get` never stops.
303    const MAX_FOLLOWUP_DEPTH: u8 = 4;
304
305    /// Boxed rather than a plain `async fn` because the recursion is real: an operation
306    /// reports its result, the model answers with another operation, and that comes back
307    /// here. A self-awaiting `async fn` has an infinitely-sized future, and the boxed type
308    /// has to name `+ Send` explicitly or `tokio::spawn` cannot take it.
309    #[allow(clippy::too_many_arguments)]
310    fn apply_action<'a>(
311        executed: ClientActionResult,
312        client_id: ClientId,
313        etcd: &'a SharedEtcd,
314        app_state: &'a Arc<AppState>,
315        llm_client: &'a OllamaClient,
316        status_tx: &'a mpsc::UnboundedSender<String>,
317        depth: u8,
318    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<Applied>> + Send + 'a>> {
319        Box::pin(async move {
320            let outcome = match executed {
321            ClientActionResult::Custom { name, data } if name == "etcd_get" => {
322                let key = data
323                    .get("key")
324                    .and_then(|v| v.as_str())
325                    .context("Missing 'key' in etcd_get")?
326                    .to_string();
327                Self::perform_get(client_id, etcd, key).await?
328            }
329            ClientActionResult::Custom { name, data } if name == "etcd_put" => {
330                let key = data
331                    .get("key")
332                    .and_then(|v| v.as_str())
333                    .context("Missing 'key' in etcd_put")?
334                    .to_string();
335                let value = data
336                    .get("value")
337                    .and_then(|v| v.as_str())
338                    .context("Missing 'value' in etcd_put")?
339                    .to_string();
340                Self::perform_put(client_id, etcd, key, value).await?
341            }
342            ClientActionResult::Custom { name, data } if name == "etcd_delete" => {
343                let key = data
344                    .get("key")
345                    .and_then(|v| v.as_str())
346                    .context("Missing 'key' in etcd_delete")?
347                    .to_string();
348                Self::perform_delete(client_id, etcd, key).await?
349            }
350            ClientActionResult::Disconnect => {
351                info!("etcd client {} disconnecting", client_id);
352                return Ok(Applied::Disconnect);
353            }
354            ClientActionResult::WaitForMore => {
355                return Ok(Applied::Ran("wait_for_more".to_string()))
356            }
357            ClientActionResult::NoAction => return Ok(Applied::Ran("no_action".to_string())),
358            // Not swallowed: an action this client cannot carry out says so, rather than
359            // looking identical to success.
360            ClientActionResult::Custom { name, .. } => {
361                return Ok(Applied::Ran(format!(
362                    "custom result '{name}' is not handled by the etcd client"
363                )))
364            }
365            ClientActionResult::SendData(_) => {
366                return Ok(Applied::Ran(
367                    "send_data has no meaning for an etcd client (etcd-client owns the gRPC connection)"
368                        .to_string(),
369                ))
370            }
371            ClientActionResult::Multiple(_) => {
372                return Ok(Applied::Ran(
373                    "multiple results are not produced by the etcd client".to_string(),
374                ))
375            }
376        };
377
378            let detail = outcome.detail.clone();
379            let state_clone = app_state.clone();
380            let llm_clone = llm_client.clone();
381            let status_clone = status_tx.clone();
382            let etcd_clone = etcd.clone();
383            let notify_handle = tokio::spawn(async move {
384                Self::notify_response(
385                    client_id,
386                    outcome.event_data,
387                    etcd_clone,
388                    state_clone,
389                    llm_clone,
390                    status_clone,
391                    depth,
392                )
393                .await;
394            });
395            app_state
396                .register_client_task(client_id, notify_handle)
397                .await;
398
399            Ok(Applied::Ran(detail))
400        })
401    }
402
403    /// Execute a get operation on the live session.
404    async fn perform_get(
405        client_id: ClientId,
406        etcd: &SharedEtcd,
407        key: String,
408    ) -> Result<EtcdOutcome> {
409        info!("etcd client {} getting key: {}", client_id, key);
410
411        let resp = {
412            // A clone of the client, not the guard: `etcd_client::Client` is a cheap handle
413            // over one tonic channel, and holding the lock across the RPC would serialise every
414            // operation behind the slowest one.
415            let mut client = etcd.lock().await.clone();
416            client
417                .get(key.clone(), None)
418                .await
419                .context("Failed to get key from etcd")?
420        };
421
422        // Build response data
423        let kvs: Vec<serde_json::Value> = resp
424            .kvs()
425            .iter()
426            .map(|kv| {
427                serde_json::json!({
428                    "key": String::from_utf8_lossy(kv.key()).to_string(),
429                    "value": String::from_utf8_lossy(kv.value()).to_string(),
430                    "create_revision": kv.create_revision(),
431                    "mod_revision": kv.mod_revision(),
432                    "version": kv.version(),
433                    "lease": kv.lease(),
434                })
435            })
436            .collect();
437
438        debug!(
439            "etcd client {} received {} key-value pairs",
440            client_id,
441            kvs.len()
442        );
443
444        Ok(EtcdOutcome {
445            detail: format!("etcd_get '{}' -> {} key-value pair(s)", key, kvs.len()),
446            event_data: serde_json::json!({
447                "operation": "get",
448                "key": key,
449                "kvs": kvs,
450                "count": resp.count(),
451                "more": resp.more(),
452            }),
453        })
454    }
455
456    /// Execute a put operation on the live session.
457    async fn perform_put(
458        client_id: ClientId,
459        etcd: &SharedEtcd,
460        key: String,
461        value: String,
462    ) -> Result<EtcdOutcome> {
463        info!("etcd client {} putting key: {} = {}", client_id, key, value);
464
465        let resp = {
466            // A clone of the client, not the guard: `etcd_client::Client` is a cheap handle
467            // over one tonic channel, and holding the lock across the RPC would serialise every
468            // operation behind the slowest one.
469            let mut client = etcd.lock().await.clone();
470            client
471                .put(key.clone(), value.clone(), None)
472                .await
473                .context("Failed to put key to etcd")?
474        };
475
476        let revision = resp.header().map(|h| h.revision()).unwrap_or(0);
477        debug!(
478            "etcd client {} put completed, header revision: {}",
479            client_id, revision
480        );
481
482        Ok(EtcdOutcome {
483            detail: format!("etcd_put '{}' -> revision {}", key, revision),
484            event_data: serde_json::json!({
485                "operation": "put",
486                "key": key,
487                "value": value,
488                "revision": revision,
489            }),
490        })
491    }
492
493    /// Execute a delete operation on the live session.
494    async fn perform_delete(
495        client_id: ClientId,
496        etcd: &SharedEtcd,
497        key: String,
498    ) -> Result<EtcdOutcome> {
499        info!("etcd client {} deleting key: {}", client_id, key);
500
501        let resp = {
502            // A clone of the client, not the guard: `etcd_client::Client` is a cheap handle
503            // over one tonic channel, and holding the lock across the RPC would serialise every
504            // operation behind the slowest one.
505            let mut client = etcd.lock().await.clone();
506            client
507                .delete(key.clone(), None)
508                .await
509                .context("Failed to delete key from etcd")?
510        };
511
512        debug!(
513            "etcd client {} delete completed, deleted {} keys",
514            client_id,
515            resp.deleted()
516        );
517
518        Ok(EtcdOutcome {
519            detail: format!("etcd_delete '{}' -> {} key(s) deleted", key, resp.deleted()),
520            event_data: serde_json::json!({
521                "operation": "delete",
522                "key": key,
523                "deleted": resp.deleted(),
524            }),
525        })
526    }
527
528    /// Raise `etcd_response_received` for a completed operation.
529    #[allow(clippy::too_many_arguments)]
530    async fn notify_response(
531        client_id: ClientId,
532        event_data: serde_json::Value,
533        etcd: SharedEtcd,
534        app_state: Arc<AppState>,
535        llm_client: OllamaClient,
536        status_tx: mpsc::UnboundedSender<String>,
537        depth: u8,
538    ) {
539        let response_event = Event::new(&ETCD_CLIENT_RESPONSE_RECEIVED_EVENT, event_data);
540
541        let protocol = Arc::new(EtcdClientProtocol::new());
542        let instruction = app_state
543            .with_client_mut(client_id, |client| client.instruction.clone())
544            .await
545            .unwrap_or_default();
546        let memory = app_state
547            .with_client_mut(client_id, |client| {
548                client
549                    .get_protocol_field("memory")
550                    .and_then(|v| v.as_str())
551                    .map(|s| s.to_string())
552            })
553            .await
554            .flatten()
555            .unwrap_or_default();
556
557        match call_llm_for_client(
558            &llm_client,
559            &app_state,
560            client_id.to_string(),
561            &instruction,
562            &memory,
563            Some(&response_event),
564            protocol.as_ref(),
565            &status_tx,
566        )
567        .await
568        {
569            Ok(result) => {
570                if let Some(mem) = result.memory_updates {
571                    app_state
572                        .with_client_mut(client_id, |client| {
573                            client.set_protocol_field("memory".to_string(), serde_json::json!(mem));
574                        })
575                        .await;
576                }
577                debug!(
578                    "etcd client {} LLM generated {} actions after operation",
579                    client_id,
580                    result.actions.len()
581                );
582
583                // Run them. They used to be counted and thrown away, so the chain was
584                // exactly one operation deep: the model was asked what to do after a put,
585                // answered "now get it", and nothing happened. Boxed because the cycle is
586                // real (operation -> report -> operation) and bounded so it terminates.
587                if depth + 1 >= Self::MAX_FOLLOWUP_DEPTH {
588                    if !result.actions.is_empty() {
589                        warn!(
590                            "etcd client {} reached the follow-up depth limit ({}); \
591                             dropping {} action(s)",
592                            client_id,
593                            Self::MAX_FOLLOWUP_DEPTH,
594                            result.actions.len()
595                        );
596                    }
597                    return;
598                }
599                for action in result.actions {
600                    let executed = match protocol.execute_action(action) {
601                        Ok(executed) => executed,
602                        Err(e) => {
603                            error!("etcd client {} rejected follow-up action: {}", client_id, e);
604                            continue;
605                        }
606                    };
607                    match Self::apply_action(
608                        executed,
609                        client_id,
610                        &etcd,
611                        &app_state,
612                        &llm_client,
613                        &status_tx,
614                        depth + 1,
615                    )
616                    .await
617                    {
618                        Ok(Applied::Ran(detail)) => {
619                            info!("etcd client {} follow-up: {}", client_id, detail)
620                        }
621                        Ok(Applied::Disconnect) => break,
622                        Err(e) => {
623                            error!("etcd client {} follow-up action failed: {}", client_id, e)
624                        }
625                    }
626                }
627            }
628            Err(e) => {
629                error!(
630                    "etcd client {} LLM call failed after operation: {}",
631                    client_id, e
632                );
633            }
634        }
635    }
636}