1pub mod actions;
3
4pub use actions::MysqlClientProtocol;
5
6use anyhow::{Context, Result};
7use mysql_async::{prelude::*, Conn, OptsBuilder, Row};
8use std::net::SocketAddr;
9use std::sync::Arc;
10use tokio::sync::{mpsc, Mutex};
11use tracing::{error, info, trace, warn};
12
13use crate::client::llm_budget::call_llm_for_client;
14use crate::client::mysql::actions::{
15 MYSQL_CLIENT_CONNECTED_EVENT, MYSQL_CLIENT_RESULT_RECEIVED_EVENT,
16};
17use crate::llm::actions::client_trait::{Client, ClientActionResult};
18use crate::llm::ollama_client::OllamaClient;
19use crate::llm::ClientLlmResult;
20use crate::protocol::Event;
21use crate::state::app_state::AppState;
22use crate::state::client_handles::{ClientCommand, ClientSendOutcome};
23use crate::state::{ClientId, ClientStatus};
24
25enum Applied {
31 Query(QueryOutcome),
33 Disconnect,
35 Nothing(&'static str),
37}
38
39struct QueryOutcome {
45 query: String,
46 rows: Vec<serde_json::Value>,
47 affected_rows: u64,
48 last_insert_id: Option<u64>,
49}
50
51const MAX_FOLLOWUP_DEPTH: usize = 4;
58
59pub struct MysqlClient;
61
62impl MysqlClient {
63 pub async fn connect_with_llm_actions(
65 remote_addr: String,
66 llm_client: OllamaClient,
67 app_state: Arc<AppState>,
68 status_tx: mpsc::UnboundedSender<String>,
69 client_id: ClientId,
70 startup_params: Option<crate::protocol::StartupParams>,
71 ) -> Result<SocketAddr> {
72 let username = startup_params
84 .as_ref()
85 .map(|p| p.get_optional_string("username"))
86 .transpose()?
87 .flatten()
88 .unwrap_or_else(|| "root".to_string());
89 let password = startup_params
90 .as_ref()
91 .map(|p| p.get_optional_string("password"))
92 .transpose()?
93 .flatten()
94 .unwrap_or_default();
95 let database: Option<String> = startup_params
96 .as_ref()
97 .map(|p| p.get_optional_string("database"))
98 .transpose()?
99 .flatten();
100
101 let (host, port) = if let Some((h, p)) = remote_addr.split_once(':') {
103 (h.to_string(), p.parse::<u16>().context("Invalid port")?)
104 } else {
105 (remote_addr.clone(), 3306)
106 };
107
108 let mut opts_builder = OptsBuilder::default()
119 .ip_or_hostname(&host)
120 .tcp_port(port)
121 .user(Some(&username))
122 .pass(Some(&password))
123 .max_allowed_packet(Some(16 * 1024 * 1024))
124 .wait_timeout(Some(28800))
125 .prefer_socket(false);
126
127 if let Some(db) = database.as_ref() {
128 opts_builder = opts_builder.db_name(Some(db.as_str()));
129 }
130
131 let conn = Conn::new(opts_builder)
133 .await
134 .context(format!("Failed to connect to MySQL at {}", remote_addr))?;
135
136 info!("MySQL client {} connected to {}", client_id, remote_addr);
137
138 let socket_addr: SocketAddr = format!("{}:{}", host, port)
141 .parse()
142 .context("Failed to parse socket address")?;
143
144 app_state
146 .update_client_status(client_id, ClientStatus::Connected)
147 .await;
148 let _ = status_tx.send(format!(
149 "[CLIENT] MySQL client {} connected to {}",
150 client_id, remote_addr
151 ));
152 let _ = status_tx.send("__UPDATE_UI__".to_string());
153
154 let conn_arc = Arc::new(Mutex::new(conn));
158 let protocol = Arc::new(MysqlClientProtocol::new());
159
160 let command_rx =
164 crate::client::command_support::register_command_channel(&app_state, client_id).await;
165 let cmd_task = tokio::spawn(Self::command_loop(
166 command_rx,
167 protocol.clone(),
168 conn_arc.clone(),
169 client_id,
170 llm_client.clone(),
171 app_state.clone(),
172 status_tx.clone(),
173 ));
174 app_state.register_client_task(client_id, cmd_task).await;
175
176 if let Some(instruction) = app_state.get_instruction_for_client(client_id).await {
178 let event = Event::new(
179 &MYSQL_CLIENT_CONNECTED_EVENT,
180 serde_json::json!({
181 "remote_addr": remote_addr,
182 }),
183 );
184
185 let memory = app_state
186 .get_memory_for_client(client_id)
187 .await
188 .unwrap_or_default();
189
190 let protocol_clone = protocol.clone();
191 let conn_clone = conn_arc.clone();
192 let app_state_clone = app_state.clone();
193 let status_tx_clone = status_tx.clone();
194
195 let task_registrar = app_state.clone();
196 let handle = tokio::spawn(async move {
197 match call_llm_for_client(
198 &llm_client,
199 &app_state_clone,
200 client_id.to_string(),
201 &instruction,
202 &memory,
203 Some(&event),
204 protocol_clone.as_ref(),
205 &status_tx_clone,
206 )
207 .await
208 {
209 Ok(ClientLlmResult {
210 actions,
211 memory_updates,
212 }) => {
213 if let Some(mem) = memory_updates {
215 app_state_clone.set_memory_for_client(client_id, mem).await;
216 }
217
218 for action in actions {
220 if let Err(e) = Self::execute_llm_action(
221 client_id,
222 action,
223 &protocol_clone,
224 &conn_clone,
225 &app_state_clone,
226 &llm_client,
227 &status_tx_clone,
228 0,
229 )
230 .await
231 {
232 error!("Error executing MySQL action: {}", e);
233 }
234 }
235 }
236 Err(e) => {
237 error!("LLM error for MySQL client {}: {}", client_id, e);
238 }
239 }
240 });
241 task_registrar.register_client_task(client_id, handle).await;
242 }
243
244 Ok(socket_addr)
245 }
246
247 #[allow(clippy::too_many_arguments)]
255 async fn command_loop(
256 mut command_rx: mpsc::Receiver<ClientCommand>,
257 protocol: Arc<MysqlClientProtocol>,
258 conn: Arc<Mutex<Conn>>,
259 client_id: ClientId,
260 llm_client: OllamaClient,
261 app_state: Arc<AppState>,
262 status_tx: mpsc::UnboundedSender<String>,
263 ) {
264 use crate::llm::actions::protocol_trait::Protocol;
265 use crate::state::AccessLogOwner;
266
267 while let Some(command) = command_rx.recv().await {
268 let action = command.action.clone();
269
270 let mut follow_up: Option<QueryOutcome> = None;
271 let outcome = match protocol.execute_action(action.clone()) {
272 Err(e) => Ok(ClientSendOutcome::Rejected {
273 error: e.to_string(),
274 }),
275 Ok(result) => {
276 match Self::apply_action(result, &conn, client_id, &app_state, &status_tx).await
277 {
278 Err(e) => Err(e),
279 Ok(Applied::Disconnect) => Ok(ClientSendOutcome::Disconnected),
280 Ok(Applied::Nothing(what)) => Ok(ClientSendOutcome::Executed {
281 detail: what.to_string(),
282 }),
283 Ok(Applied::Query(outcome)) => {
284 let detail = format!(
285 "query executed by mysql_async ({} rows, {} affected); \
286 no byte count — the driver owns the socket: {}",
287 outcome.rows.len(),
288 outcome.affected_rows,
289 crate::utils::truncate::truncate_for_log(&outcome.query, 80)
290 );
291 follow_up = Some(outcome);
292 Ok(ClientSendOutcome::Executed { detail })
293 }
294 }
295 }
296 };
297
298 let outcome_json = match &outcome {
299 Ok(outcome) => serde_json::to_value(outcome).unwrap_or(serde_json::Value::Null),
300 Err(e) => serde_json::json!({"error": e.to_string()}),
301 };
302 app_state
303 .record_access_log(
304 AccessLogOwner::Client(client_id.as_u32()),
305 protocol.protocol_name(),
306 None,
307 "injected_action",
308 action,
309 vec![outcome_json],
310 )
311 .await;
312
313 let disconnect = matches!(outcome, Ok(ClientSendOutcome::Disconnected));
314 if let Err(e) = &outcome {
315 error!("MySQL client {} injected action failed: {}", client_id, e);
316 let _ = status_tx.send(format!(
317 "[WARN] Client {} injected action failed: {}",
318 client_id, e
319 ));
320 }
321 let _ = status_tx.send("__UPDATE_UI__".to_string());
322 crate::client::command_support::reply(command, outcome);
323
324 if disconnect {
325 Self::mark_disconnected(client_id, &app_state, &status_tx).await;
326 break;
327 }
328
329 if let Some(outcome) = follow_up {
333 Self::report_query_result(
334 client_id,
335 outcome,
336 &protocol,
337 &conn,
338 &app_state,
339 &llm_client,
340 &status_tx,
341 0,
342 )
343 .await;
344 }
345 }
346
347 app_state.remove_client_handle(client_id).await;
350 let _ = status_tx.send("__UPDATE_UI__".to_string());
351 }
352
353 #[allow(clippy::too_many_arguments)]
363 fn execute_llm_action<'a>(
364 client_id: ClientId,
365 action: serde_json::Value,
366 protocol: &'a Arc<MysqlClientProtocol>,
367 conn: &'a Arc<Mutex<Conn>>,
368 app_state: &'a Arc<AppState>,
369 llm_client: &'a OllamaClient,
370 status_tx: &'a mpsc::UnboundedSender<String>,
371 depth: usize,
372 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<()>> + Send + 'a>> {
373 Box::pin(async move {
374 match Self::apply_action(
375 protocol.execute_action(action)?,
376 conn,
377 client_id,
378 app_state,
379 status_tx,
380 )
381 .await?
382 {
383 Applied::Query(outcome) => {
384 Self::report_query_result(
385 client_id, outcome, protocol, conn, app_state, llm_client, status_tx, depth,
386 )
387 .await;
388 }
389 Applied::Disconnect => {
390 info!("MySQL client {} disconnecting", client_id);
391 Self::mark_disconnected(client_id, app_state, status_tx).await;
392 }
393 Applied::Nothing(what) => {
394 trace!("MySQL client {} action had no effect: {}", client_id, what);
395 }
396 }
397 Ok(())
398 })
399 }
400
401 async fn apply_action(
404 result: ClientActionResult,
405 conn: &Arc<Mutex<Conn>>,
406 client_id: ClientId,
407 app_state: &Arc<AppState>,
408 status_tx: &mpsc::UnboundedSender<String>,
409 ) -> Result<Applied> {
410 match result {
411 ClientActionResult::Custom { name, data } if name == "mysql_query" => {
412 let query_str = data
413 .get("query")
414 .and_then(|v| v.as_str())
415 .context("Missing 'query' in mysql_query action data")?
416 .to_string();
417
418 trace!("MySQL client {} executing query: {}", client_id, query_str);
419
420 let result: Result<(Vec<Row>, u64, Option<u64>)> = {
423 let mut conn_guard = conn.lock().await;
424 match conn_guard.query(&query_str).await {
425 Ok(rows) => Ok((
426 rows,
427 conn_guard.affected_rows(),
428 conn_guard.last_insert_id(),
429 )),
430 Err(e) => Err(anyhow::Error::from(e).context("Failed to execute query")),
431 }
432 };
433
434 match result {
435 Ok((rows, affected_rows, last_insert_id)) => {
436 let json_rows = rows_to_json(&rows);
437 info!(
438 "MySQL client {} query returned {} rows, {} affected",
439 client_id,
440 json_rows.len(),
441 affected_rows
442 );
443 Ok(Applied::Query(QueryOutcome {
444 query: query_str,
445 rows: json_rows,
446 affected_rows,
447 last_insert_id: last_insert_id.filter(|id| *id != 0),
448 }))
449 }
450 Err(e) => {
451 error!("MySQL client {} query error: {}", client_id, e);
452 app_state
453 .update_client_status(client_id, ClientStatus::Error(e.to_string()))
454 .await;
455 let _ = status_tx.send("__UPDATE_UI__".to_string());
456 Err(e)
457 }
458 }
459 }
460 ClientActionResult::Custom { name, .. } => {
461 trace!(
462 "MySQL client {} ignoring custom result '{}'",
463 client_id,
464 name
465 );
466 Ok(Applied::Nothing("custom result not handled by this client"))
467 }
468 ClientActionResult::Disconnect => Ok(Applied::Disconnect),
469 ClientActionResult::WaitForMore => Ok(Applied::Nothing("wait_for_more")),
470 ClientActionResult::NoAction => Ok(Applied::Nothing("no_action")),
471 ClientActionResult::SendData(_) => Ok(Applied::Nothing(
472 "send_data is not meaningful for a mysql_async connection",
473 )),
474 ClientActionResult::Multiple(_) => Ok(Applied::Nothing(
475 "multiple results not handled by this client",
476 )),
477 }
478 }
479
480 #[allow(clippy::too_many_arguments)]
498 async fn report_query_result(
499 client_id: ClientId,
500 outcome: QueryOutcome,
501 protocol: &Arc<MysqlClientProtocol>,
502 conn: &Arc<Mutex<Conn>>,
503 app_state: &Arc<AppState>,
504 llm_client: &OllamaClient,
505 status_tx: &mpsc::UnboundedSender<String>,
506 depth: usize,
507 ) {
508 let Some(instruction) = app_state.get_instruction_for_client(client_id).await else {
509 return;
510 };
511
512 let mut data = serde_json::json!({
513 "query": outcome.query,
514 "result": outcome.rows,
515 "row_count": outcome.rows.len(),
516 "affected_rows": outcome.affected_rows,
517 });
518 if let Some(id) = outcome.last_insert_id {
519 data["last_insert_id"] = serde_json::json!(id);
520 }
521 let event = Event::new(&MYSQL_CLIENT_RESULT_RECEIVED_EVENT, data);
522
523 let memory = app_state
524 .get_memory_for_client(client_id)
525 .await
526 .unwrap_or_default();
527
528 match call_llm_for_client(
529 llm_client,
530 app_state,
531 client_id.to_string(),
532 &instruction,
533 &memory,
534 Some(&event),
535 protocol.as_ref(),
536 status_tx,
537 )
538 .await
539 {
540 Ok(ClientLlmResult {
541 actions,
542 memory_updates,
543 }) => {
544 if let Some(mem) = memory_updates {
546 app_state.set_memory_for_client(client_id, mem).await;
547 }
548
549 if actions.is_empty() {
550 return;
551 }
552 if depth >= MAX_FOLLOWUP_DEPTH {
553 warn!(
554 "MySQL client {} reached the follow-up depth bound ({}); dropping {} \
555 action(s) rather than looping. A model answering every result with \
556 another query would otherwise never stop.",
557 client_id,
558 MAX_FOLLOWUP_DEPTH,
559 actions.len()
560 );
561 return;
562 }
563
564 for new_action in actions {
565 if let Err(e) = Self::execute_llm_action(
566 client_id,
567 new_action,
568 protocol,
569 conn,
570 app_state,
571 llm_client,
572 status_tx,
573 depth + 1,
574 )
575 .await
576 {
577 error!(
578 "MySQL client {} failed to execute a follow-up action: {}",
579 client_id, e
580 );
581 }
582 }
583 }
584 Err(e) => {
585 error!("LLM error for MySQL client {}: {}", client_id, e);
586 }
587 }
588 }
589
590 async fn mark_disconnected(
601 client_id: ClientId,
602 app_state: &Arc<AppState>,
603 status_tx: &mpsc::UnboundedSender<String>,
604 ) {
605 app_state.remove_client_handle(client_id).await;
606 app_state
607 .update_client_status(client_id, ClientStatus::Disconnected)
608 .await;
609 let _ = status_tx.send(format!("[CLIENT] MySQL client {} disconnected", client_id));
610 let _ = status_tx.send("__UPDATE_UI__".to_string());
611 }
612}
613
614fn rows_to_json(rows: &[Row]) -> Vec<serde_json::Value> {
616 rows.iter()
617 .map(|row| {
618 let mut obj = serde_json::Map::new();
619 for (idx, col) in row.columns_ref().iter().enumerate() {
620 let value = match row.as_ref(idx) {
621 Some(mysql_async::Value::NULL) => serde_json::Value::Null,
622 Some(mysql_async::Value::Bytes(b)) => {
623 serde_json::Value::String(String::from_utf8_lossy(b).to_string())
624 }
625 Some(mysql_async::Value::Int(i)) => serde_json::Value::Number((*i).into()),
626 Some(mysql_async::Value::UInt(u)) => serde_json::Value::Number((*u).into()),
627 Some(mysql_async::Value::Float(f)) => serde_json::Number::from_f64(*f as f64)
628 .map(serde_json::Value::Number)
629 .unwrap_or(serde_json::Value::Null),
630 Some(mysql_async::Value::Double(d)) => serde_json::Number::from_f64(*d)
631 .map(serde_json::Value::Number)
632 .unwrap_or(serde_json::Value::Null),
633 Some(mysql_async::Value::Date(y, m, d, h, min, s, us)) => {
634 serde_json::Value::String(format!(
635 "{:04}-{:02}-{:02} {:02}:{:02}:{:02}.{:06}",
636 y, m, d, h, min, s, us
637 ))
638 }
639 Some(mysql_async::Value::Time(is_neg, d, h, m, s, us)) => {
640 let sign = if *is_neg { "-" } else { "" };
641 serde_json::Value::String(format!(
642 "{}{} {:02}:{:02}:{:02}.{:06}",
643 sign, d, h, m, s, us
644 ))
645 }
646 None => serde_json::Value::Null,
647 };
648 obj.insert(col.name_str().to_string(), value);
649 }
650 serde_json::Value::Object(obj)
651 })
652 .collect()
653}