1pub 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
23type SharedEtcd = Arc<Mutex<etcd_client::Client>>;
31
32struct EtcdOutcome {
35 event_data: serde_json::Value,
36 detail: String,
37}
38
39enum Applied {
42 Ran(String),
44 Disconnect,
46}
47
48pub struct EtcdClient;
50
51impl EtcdClient {
52 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 let endpoints = vec![remote_addr.clone()];
64
65 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 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 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 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 let connected_event = Event::new(
118 &ETCD_CLIENT_CONNECTED_EVENT,
119 serde_json::json!({
120 "remote_addr": remote_addr,
121 }),
122 );
123
124 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 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 Ok("0.0.0.0:0".parse().unwrap())
205 }
206
207 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 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 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 const MAX_FOLLOWUP_DEPTH: u8 = 4;
304
305 #[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 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 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 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 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 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 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 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 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 #[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 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}