Skip to main content

netget/client/mysql/
mod.rs

1//! MySQL client implementation
2pub 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
25/// What one executed action did to the MySQL connection.
26///
27/// Returned by [`MysqlClient::apply_action`], which is the single place a `mysql_query`
28/// reaches the server — the connected-event LLM path and injected dashboard commands both
29/// go through it.
30enum Applied {
31    /// A query ran; what the server said about it is reported back to the model.
32    Query(QueryOutcome),
33    /// The session should end.
34    Disconnect,
35    /// The action executed but touched the connection in no way.
36    Nothing(&'static str),
37}
38
39/// What the server returned for one query.
40///
41/// `affected_rows` and `last_insert_id` come from the server's OK packet, read off the
42/// connection before its guard is released, not derived from the result set: an `INSERT`
43/// returns no rows and affects one, and a `SELECT` returns rows and affects none.
44struct QueryOutcome {
45    query: String,
46    rows: Vec<serde_json::Value>,
47    affected_rows: u64,
48    last_insert_id: Option<u64>,
49}
50
51/// How many action -> query -> result -> action turns one client will take before stopping.
52///
53/// The chain is genuinely self-referential, so it needs a bound rather than silence: a model
54/// that answers every result with another query would otherwise run forever. Four is enough
55/// for the flows this client is for (connect, query, react to the rows, one more query) and
56/// small enough that a runaway costs four LLM calls rather than an unbounded number.
57const MAX_FOLLOWUP_DEPTH: usize = 4;
58
59/// MySQL client that connects to a MySQL server
60pub struct MysqlClient;
61
62impl MysqlClient {
63    /// Connect to a MySQL server with integrated LLM actions
64    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        // `get_optional_string`, not `get_string`. All three parameters are declared
73        // `required: false`, but `get_string` **errors when the key is absent** ("Required
74        // string parameter '{}' is missing or not a string"), and `.transpose()?` propagates
75        // that. So the `unwrap_or_else` defaults only ever applied when `startup_params` was
76        // `None` *entirely*: pass any subset and connecting failed.
77        //
78        // The dashboard makes that the normal case, not a corner one - `ServerForm`/
79        // `ClientForm` omit blank fields and return `None` only when nothing at all was
80        // filled in - so filling in just `database` on the MySQL create form failed with
81        // "Required string parameter 'username' is missing". The PostgreSQL client already
82        // used the optional accessor.
83        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        // Parse remote_addr to get host and port
102        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        // Build MySQL connection options.
109        //
110        // `max_allowed_packet` / `wait_timeout` / `prefer_socket` are pinned deliberately.
111        // Left unset, `Conn::new` asks the server for `@@max_allowed_packet`,
112        // `@@wait_timeout` and `@@socket` and feeds each answer to `from_value`, which
113        // **panics** rather than erroring when the value is not the type it expects. The
114        // server at the other end of a NetGet client is frequently one whose replies a model
115        // composed, so that panic is reachable from an ordinary LLM answer. Supplying the
116        // values here means the settings query is never issued. `prefer_socket(false)` is
117        // right on its own terms too: the user gave us a TCP address to use.
118        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        // Connect to MySQL server
132        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        // For SocketAddr, we'll create a fake one since mysql_async doesn't expose the actual socket
139        // We'll parse the remote_addr to create a SocketAddr
140        let socket_addr: SocketAddr = format!("{}:{}", host, port)
141            .parse()
142            .context("Failed to parse socket address")?;
143
144        // Update client state
145        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        // Wrap connection in Arc<Mutex> for shared access. The command loop below holds a
155        // clone, which is also what keeps the connection alive: before it existed, a client
156        // with no instruction dropped its only `Conn` the moment this function returned.
157        let conn_arc = Arc::new(Mutex::new(conn));
158        let protocol = Arc::new(MysqlClientProtocol::new());
159
160        // Command channel for injected actions (the dashboard's [ send ]).
161        // Registered BEFORE the connected-event LLM call, which a manual `*` rule can park
162        // for minutes — the operator must be able to reach the client while it waits.
163        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        // Call LLM with connected event
177        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                        // Update memory
214                        if let Some(mem) = memory_updates {
215                            app_state_clone.set_memory_for_client(client_id, mem).await;
216                        }
217
218                        // Execute actions
219                        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    /// Drain injected commands until the channel closes (the client was removed or stopped)
248    /// or an injected `disconnect` ends the session.
249    ///
250    /// The generic `command_support::handle_stream_client_command` cannot serve this client:
251    /// there is no socket NetGet owns — `mysql_async` does — so `execute_query` yields
252    /// `ClientActionResult::Custom` and the effect goes through the shared
253    /// [`Self::apply_action`], the same function the LLM path uses.
254    #[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            // The model must see the result of a user-injected query exactly as it sees the
330            // result of one it asked for. Done *after* the reply so the dashboard is not
331            // blocked for the length of an LLM round-trip.
332            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        // Every exit path lands here: drop the command handle so the dashboard stops
348        // offering [ send ] on a client whose loop is gone.
349        app_state.remove_client_handle(client_id).await;
350        let _ = status_tx.send("__UPDATE_UI__".to_string());
351    }
352
353    /// Execute an action from the LLM
354    /// Execute one action from the model and, if it was a query, feed the rows back.
355    ///
356    /// Returns an explicitly boxed `+ Send` future rather than being an `async fn`. That is
357    /// not style: this and `report_query_result` call each other, and with two `async fn`s the
358    /// compiler has to prove `Send` for each in terms of the other and gives up
359    /// ("future cannot be sent between threads safely ... cannot satisfy `impl Future: Send`").
360    /// Naming the type here breaks the cycle, and `+ Send` is required anyway because this is
361    /// awaited inside a `tokio::spawn`.
362    #[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    /// Put one executed action on the connection. Shared by the connected-event LLM path and
402    /// injected commands so the query path exists exactly once.
403    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                // The OK packet's counters live on the connection and are overwritten by the
421                // next query, so they are read under the same guard as the query itself.
422                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    /// Raise `mysql_result_received` with a query's rows and run whatever the model answers.
481    ///
482    /// The answer is **executed**, not merely decoded. It used to be decoded and dropped: the
483    /// loop called `protocol.execute_action(..)`, which is pure - it returns a
484    /// `ClientActionResult::Custom { name: "mysql_query", .. }` and nothing put it on the
485    /// connection - and then matched everything but `Disconnect` into a `trace!`. So a model
486    /// answering `mysql_result_received` with `execute_query` (which this client advertises in
487    /// both its async and sync sets) issued no query and sent no bytes, on the LLM path and on
488    /// the dashboard's `[ send ]` path alike. A comment above it explained that "more complex
489    /// flows can be handled by the LLM in the instruction", which is the
490    /// explaining-why-the-answer-is-not-needed idiom the root CLAUDE.md names as this exact
491    /// bug.
492    ///
493    /// Closing the loop makes it self-referential - `execute_llm_action` runs a query, which
494    /// reports its result here, which may run another. `execute_llm_action` therefore returns
495    /// an explicitly boxed `+ Send` future (see its own comment) and the chain is bounded by
496    /// `MAX_FOLLOWUP_DEPTH`.
497    #[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                // Update memory
545                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    /// Mark the client disconnected **and** stop offering to send on it.
591    ///
592    /// Dropping the command handle closes the channel, which ends `command_loop`; without it
593    /// an LLM-requested `disconnect` left the client drawn as `Disconnected` while the loop
594    /// still ran and the dashboard still offered `[ send ]`. Only the *injected* disconnect
595    /// path ended the loop, because it breaks out of it directly.
596    ///
597    /// `mysql_async`'s `Conn` is closed by dropping it, and it is held behind an
598    /// `Arc<Mutex<..>>` shared with `command_loop`, so ending that loop is what releases the
599    /// last handle and with it the TCP session.
600    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
614/// Convert a MySQL result set into the JSON rows reported to the model.
615fn 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}