Skip to main content

netget/client/mssql/
mod.rs

1//! MSSQL client implementation using tiberius
2pub mod actions;
3
4pub use actions::MssqlClientProtocol;
5
6use crate::client::llm_budget::call_llm_for_client;
7use crate::client::mssql::actions::{
8    MSSQL_CLIENT_CONNECTED_EVENT, MSSQL_CLIENT_ERROR_EVENT, MSSQL_CLIENT_QUERY_RESULT_EVENT,
9};
10use crate::llm::actions::client_trait::{Client, ClientActionResult};
11use crate::llm::ollama_client::OllamaClient;
12use crate::llm::ClientLlmResult;
13use crate::protocol::Event;
14use crate::state::app_state::AppState;
15use crate::state::{ClientId, ClientStatus};
16use anyhow::{Context, Result};
17use futures::StreamExt;
18use serde_json::json;
19use std::net::SocketAddr;
20use std::sync::Arc;
21use tiberius::{AuthMethod, Client as TiberiusClient, Config, QueryItem, Row};
22use tokio::net::TcpStream;
23use tokio::sync::{mpsc, Mutex};
24use tokio_util::compat::TokioAsyncWriteCompatExt;
25use tracing::{debug, error, info, trace, warn};
26
27/// The live tiberius connection. Named because it appears in five signatures.
28type MssqlConn = TiberiusClient<tokio_util::compat::Compat<TcpStream>>;
29
30/// What one executed MSSQL action did, before any follow-up LLM call.
31///
32/// Splitting "run it" from "tell the model about it" is what lets the connected-event path
33/// and an injected dashboard command share the query path without the command loop having
34/// to block on a handler that may be parked for a human.
35enum MssqlApplied {
36    /// A query ran. `result` is the collected rows, or the server's error as text.
37    Query {
38        query: String,
39        result: std::result::Result<QueryResult, String>,
40    },
41    /// The action asked to end the session.
42    Disconnect,
43    /// The action ran but touched the connection in no way worth reporting.
44    Nothing(String),
45}
46
47/// MSSQL client that connects to an MSSQL/SQL Server
48pub struct MssqlClient;
49
50impl MssqlClient {
51    /// Connect to an MSSQL server with integrated LLM actions
52    pub async fn connect_with_llm_actions(
53        remote_addr: String,
54        llm_client: OllamaClient,
55        app_state: Arc<AppState>,
56        status_tx: mpsc::UnboundedSender<String>,
57        client_id: ClientId,
58    ) -> Result<SocketAddr> {
59        // Parse connection string (format: "host:port" or "host:port;database=db;user=user;password=pass")
60        let (host_port, config_params) = if remote_addr.contains(';') {
61            let parts: Vec<&str> = remote_addr.splitn(2, ';').collect();
62            (parts[0].to_string(), Some(parts[1].to_string()))
63        } else {
64            (remote_addr.clone(), None)
65        };
66
67        // Parse host and port
68        let (host, port) = if host_port.contains(':') {
69            let parts: Vec<&str> = host_port.split(':').collect();
70            (
71                parts[0].to_string(),
72                parts
73                    .get(1)
74                    .and_then(|p| p.parse::<u16>().ok())
75                    .unwrap_or(1433),
76            )
77        } else {
78            (host_port, 1433)
79        };
80
81        // Build tiberius config
82        let mut config = Config::new();
83        config.host(&host);
84        config.port(port);
85        config.trust_cert(); // For testing - accept self-signed certs
86                             // tiberius defaults to `EncryptionLevel::Required` whenever a TLS backend is
87                             // compiled in, and NetGet pulls tiberius with `native-tls`. NetGet's own MSSQL
88                             // server answers PRELOGIN with ENCRYPT_NOT_SUP, so with the default the client
89                             // negotiated `EncryptionLevel::On` and then tried a TLS handshake the server cannot
90                             // do - the two halves of this codebase could not talk to each other at all.
91        config.encryption(tiberius::EncryptionLevel::NotSupported);
92
93        // Parse optional connection parameters
94        if let Some(params_str) = config_params {
95            for param in params_str.split(';') {
96                let kv: Vec<&str> = param.split('=').collect();
97                if kv.len() == 2 {
98                    match kv[0].to_lowercase().as_str() {
99                        "database" => config.database(kv[1]),
100                        "user" => config.authentication(AuthMethod::sql_server(kv[1], "")),
101                        _ => {}
102                    };
103                }
104            }
105        }
106
107        // Always use no authentication for now (works with our test server)
108        // In production, parse credentials from connection string
109        config.authentication(AuthMethod::None);
110
111        // Connect to MSSQL server
112        let tcp = TcpStream::connect((host.as_str(), port))
113            .await
114            .context(format!("Failed to connect to MSSQL at {}:{}", host, port))?;
115
116        let local_addr = tcp.local_addr()?;
117        let remote_sock_addr = tcp.peer_addr()?;
118
119        info!(
120            "MSSQL client {} connected to {} (local: {})",
121            client_id, remote_sock_addr, local_addr
122        );
123
124        // Create tiberius client
125        let client = TiberiusClient::connect(config, tcp.compat_write())
126            .await
127            .context("Failed to create tiberius client")?;
128
129        // Update client state
130        app_state
131            .update_client_status(client_id, ClientStatus::Connected)
132            .await;
133        let _ = status_tx.send(format!("[CLIENT] MSSQL client {} connected", client_id));
134        let _ = status_tx.send("__UPDATE_UI__".to_string());
135
136        let client_arc = Arc::new(Mutex::new(client));
137        let client_for_connected = client_arc.clone();
138
139        // The dashboard's `[ send ]` channel, registered BEFORE the connected-event LLM call
140        // below. A dashboard-created client defaults to a `*` -> manual rule, so that call can
141        // park for minutes waiting for a human; registering after it would leave the rail
142        // reading "no command channel" for the whole park.
143        let command_rx =
144            crate::client::command_support::register_command_channel(&app_state, client_id).await;
145        let command_task = tokio::spawn(Self::command_loop(
146            client_arc.clone(),
147            command_rx,
148            client_id,
149            llm_client.clone(),
150            app_state.clone(),
151            status_tx.clone(),
152        ));
153        app_state
154            .register_client_task(client_id, command_task)
155            .await;
156
157        // Call LLM with mssql_connected event
158        if let Some(instruction) = app_state.get_instruction_for_client(client_id).await {
159            let event = Event::new(
160                &MSSQL_CLIENT_CONNECTED_EVENT,
161                json!({
162                    "remote_addr": remote_sock_addr.to_string(),
163                }),
164            );
165
166            match call_llm_for_client(
167                &llm_client,
168                &app_state,
169                client_id.to_string(),
170                &instruction,
171                &String::new(), // No memory yet for initial connection
172                Some(&event),
173                &MssqlClientProtocol::new(),
174                &status_tx,
175            )
176            .await
177            {
178                Ok(result) => {
179                    // Execute actions from LLM response. Depth 0: this is the first step of a
180                    // chain the model may extend by answering each query result with another
181                    // query.
182                    for action in result.actions {
183                        if let Err(e) = Self::execute_action_internal(
184                            client_for_connected.clone(),
185                            action,
186                            client_id,
187                            &llm_client,
188                            &app_state,
189                            &status_tx,
190                            0,
191                        )
192                        .await
193                        {
194                            error!("Failed to execute action after connect: {}", e);
195                        }
196                    }
197                }
198                Err(e) => {
199                    error!("LLM error on mssql_connected event: {}", e);
200                }
201            }
202        }
203
204        // MSSQL (via tiberius) is query-driven, not event-driven: there is no read loop,
205        // because nothing arrives that this client did not ask for. `command_loop` above is
206        // what holds the connection open and what makes queries reachable from outside an
207        // LLM round trip.
208        info!("MSSQL client {} ready for queries", client_id);
209
210        Ok(local_addr)
211    }
212
213    /// How many queries deep this client keeps following the model's answers.
214    ///
215    /// The cycle is real and it is the point of the client: a query is run, its result is
216    /// reported to the model as an event, and the model may answer that event with another
217    /// query. Nothing in that loop terminates on its own, so a model that answers every
218    /// `mssql_query_result` with another `mssql_query` recursed for as long as the process
219    /// lived, growing the heap by one boxed future per step and holding a `register_client_task`
220    /// handle for each. The bound is the fix the root CLAUDE.md prescribes for this shape, and
221    /// 4 is what the etcd client uses for the identical cycle.
222    const MAX_FOLLOWUP_DEPTH: u8 = 4;
223
224    /// Execute an action: run it against the connection, then tell the model what happened.
225    ///
226    /// Boxed rather than a plain `async fn` because the recursion is real - `follow_up` calls
227    /// back into this function - and a self-awaiting `async fn` has an infinitely-sized future.
228    /// `depth` is how many queries have already run in this chain.
229    #[allow(clippy::too_many_arguments)]
230    fn execute_action_internal<'a>(
231        client: Arc<Mutex<MssqlConn>>,
232        action: serde_json::Value,
233        client_id: ClientId,
234        llm_client: &'a OllamaClient,
235        app_state: &'a Arc<AppState>,
236        status_tx: &'a mpsc::UnboundedSender<String>,
237        depth: u8,
238    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<()>> + Send + 'a>> {
239        Box::pin(async move {
240            let applied = Self::apply_action(&client, action, client_id).await?;
241            Self::follow_up(
242                applied, client, client_id, llm_client, app_state, status_tx, depth,
243            )
244            .await;
245            Ok(())
246        })
247    }
248
249    /// Run one action against the live tiberius connection and report what it did.
250    ///
251    /// Shared by the connected-event path and by injected dashboard commands, so the query
252    /// path — and the `execute_action` validation in front of it — exists exactly once. It
253    /// deliberately makes no LLM call: [`Self::follow_up`] does that, and the command loop
254    /// spawns it rather than awaiting it.
255    async fn apply_action(
256        client: &Arc<Mutex<MssqlConn>>,
257        action: serde_json::Value,
258        client_id: ClientId,
259    ) -> Result<MssqlApplied> {
260        let protocol = MssqlClientProtocol::new();
261        match protocol.execute_action(action)? {
262            ClientActionResult::Custom { name, data } if name == "mssql_query" => {
263                let query = data
264                    .get("query")
265                    .and_then(|v| v.as_str())
266                    .context("mssql_query carried no 'query' string")?
267                    .to_string();
268                debug!("MSSQL client {} executing query: {}", client_id, query);
269
270                // The mutex is held for the query only; it is never held across an LLM call.
271                let outcome = {
272                    let mut client_guard = client.lock().await;
273                    Self::execute_and_collect_query(&mut client_guard, &query).await
274                };
275                Ok(MssqlApplied::Query {
276                    query,
277                    result: outcome.map_err(|e| e.to_string()),
278                })
279            }
280            ClientActionResult::Disconnect => Ok(MssqlApplied::Disconnect),
281            ClientActionResult::WaitForMore => {
282                trace!("MSSQL client {} waiting for more data", client_id);
283                Ok(MssqlApplied::Nothing("wait_for_more".to_string()))
284            }
285            other => Ok(MssqlApplied::Nothing(format!(
286                "unsupported action result {other:?}"
287            ))),
288        }
289    }
290
291    /// Raise the event an applied action produced and execute whatever the handler answers.
292    ///
293    /// `depth` is how many queries have already run in this chain; see
294    /// [`Self::MAX_FOLLOWUP_DEPTH`].
295    #[allow(clippy::too_many_arguments)]
296    async fn follow_up(
297        applied: MssqlApplied,
298        client: Arc<Mutex<MssqlConn>>,
299        client_id: ClientId,
300        llm_client: &OllamaClient,
301        app_state: &Arc<AppState>,
302        status_tx: &mpsc::UnboundedSender<String>,
303        depth: u8,
304    ) {
305        let protocol = Arc::new(MssqlClientProtocol::new());
306
307        let event = match applied {
308            MssqlApplied::Disconnect => {
309                info!("MSSQL client {} disconnecting", client_id);
310                // Stop offering [ send ] on a session that is going away.
311                app_state.remove_client_handle(client_id).await;
312                app_state
313                    .update_client_status(client_id, ClientStatus::Disconnected)
314                    .await;
315                let _ = status_tx.send("__UPDATE_UI__".to_string());
316                return;
317            }
318            MssqlApplied::Nothing(detail) => {
319                trace!(
320                    "MSSQL client {} action produced nothing: {}",
321                    client_id,
322                    detail
323                );
324                return;
325            }
326            MssqlApplied::Query {
327                result: Ok(result), ..
328            } => {
329                debug!(
330                    "MSSQL client {} received {} columns, {} rows",
331                    client_id,
332                    result.columns.len(),
333                    result.rows.len()
334                );
335                Event::new(
336                    &MSSQL_CLIENT_QUERY_RESULT_EVENT,
337                    json!({
338                        "columns": result.columns,
339                        "rows": result.rows,
340                        "rows_affected": result.rows_affected,
341                    }),
342                )
343            }
344            MssqlApplied::Query {
345                result: Err(e),
346                query,
347            } => {
348                error!("MSSQL query error on '{}': {}", query, e);
349                Event::new(
350                    &MSSQL_CLIENT_ERROR_EVENT,
351                    json!({
352                        "error_number": 50000,
353                        "message": e,
354                    }),
355                )
356            }
357        };
358
359        let Some(instruction) = app_state.get_instruction_for_client(client_id).await else {
360            return;
361        };
362        let memory = app_state
363            .get_memory_for_client(client_id)
364            .await
365            .unwrap_or_default();
366
367        match call_llm_for_client(
368            llm_client,
369            app_state,
370            client_id.to_string(),
371            &instruction,
372            &memory,
373            Some(&event),
374            protocol.as_ref(),
375            status_tx,
376        )
377        .await
378        {
379            Ok(ClientLlmResult {
380                actions,
381                memory_updates,
382            }) => {
383                if let Some(mem) = memory_updates {
384                    app_state.set_memory_for_client(client_id, mem).await;
385                }
386                // Say what is being dropped and why, rather than letting the chain stop
387                // silently - a model whose plan needed a fifth step should be able to see
388                // from the log that it was cut off, not that its action was ignored.
389                if depth + 1 >= Self::MAX_FOLLOWUP_DEPTH {
390                    if !actions.is_empty() {
391                        warn!(
392                            "MSSQL client {} reached the follow-up depth limit of {}; dropping \
393                             {} further action(s)",
394                            client_id,
395                            Self::MAX_FOLLOWUP_DEPTH,
396                            actions.len()
397                        );
398                    }
399                    return;
400                }
401
402                for follow_action in actions {
403                    if let Err(e) = Self::execute_action_internal(
404                        client.clone(),
405                        follow_action,
406                        client_id,
407                        llm_client,
408                        app_state,
409                        status_tx,
410                        depth + 1,
411                    )
412                    .await
413                    {
414                        error!("Failed to execute follow-up action: {}", e);
415                    }
416                }
417            }
418            Err(e) => {
419                error!("LLM error for MSSQL client {}: {}", client_id, e);
420            }
421        }
422    }
423
424    /// Drain injected commands (the dashboard's `[ send ]`) until the channel closes or an
425    /// injected `disconnect` ends the session.
426    ///
427    /// This task is also what keeps the tiberius connection alive: before it existed the
428    /// only `Arc` to the connection was a local in `connect_with_llm_actions`, so the socket
429    /// was dropped the moment that function returned.
430    ///
431    /// Outcome semantics: tiberius owns the TDS framing, so there is no byte count this loop
432    /// can honestly claim. A query that ran reports `Executed` naming the row/column counts;
433    /// a query the server refused is an `Err`, never a quieter `Sent`.
434    async fn command_loop(
435        client: Arc<Mutex<MssqlConn>>,
436        mut command_rx: mpsc::Receiver<crate::state::client_handles::ClientCommand>,
437        client_id: ClientId,
438        llm_client: OllamaClient,
439        app_state: Arc<AppState>,
440        status_tx: mpsc::UnboundedSender<String>,
441    ) {
442        use crate::llm::actions::protocol_trait::Protocol;
443        use crate::state::client_handles::ClientSendOutcome;
444        use crate::state::AccessLogOwner;
445
446        let protocol = MssqlClientProtocol::new();
447
448        while let Some(command) = command_rx.recv().await {
449            let action = command.action.clone();
450            let mut applied_for_follow_up = None;
451            let mut disconnect = false;
452
453            let outcome: Result<ClientSendOutcome> =
454                match Self::apply_action(&client, action.clone(), client_id).await {
455                    // `apply_action` only errors when the protocol rejected the action.
456                    Err(e) => Ok(ClientSendOutcome::Rejected {
457                        error: e.to_string(),
458                    }),
459                    Ok(MssqlApplied::Disconnect) => {
460                        disconnect = true;
461                        Ok(ClientSendOutcome::Disconnected)
462                    }
463                    Ok(MssqlApplied::Nothing(detail)) => Ok(ClientSendOutcome::Executed { detail }),
464                    Ok(MssqlApplied::Query {
465                        query,
466                        result: Err(e),
467                    }) => Err(anyhow::anyhow!("query '{query}' failed: {e}")),
468                    Ok(MssqlApplied::Query {
469                        query,
470                        result: Ok(result),
471                    }) => {
472                        let detail = format!(
473                            "execute_query '{}': {} row(s), {} column(s)",
474                            query,
475                            result.rows.len(),
476                            result.columns.len()
477                        );
478                        applied_for_follow_up = Some(MssqlApplied::Query {
479                            query,
480                            result: Ok(result),
481                        });
482                        Ok(ClientSendOutcome::Executed { detail })
483                    }
484                };
485
486            let outcome_json = match &outcome {
487                Ok(outcome) => serde_json::to_value(outcome).unwrap_or(serde_json::Value::Null),
488                Err(e) => serde_json::json!({"error": e.to_string()}),
489            };
490            app_state
491                .record_access_log(
492                    AccessLogOwner::Client(client_id.as_u32()),
493                    protocol.protocol_name(),
494                    None,
495                    "injected_action",
496                    action,
497                    vec![outcome_json],
498                )
499                .await;
500
501            if let Err(e) = &outcome {
502                error!("MSSQL client {} injected action failed: {}", client_id, e);
503                let _ = status_tx.send(format!(
504                    "[WARN] Client {} injected action failed: {}",
505                    client_id, e
506                ));
507            }
508            let _ = status_tx.send("__UPDATE_UI__".to_string());
509            crate::client::command_support::reply(command, outcome);
510
511            if disconnect {
512                app_state.remove_client_handle(client_id).await;
513                app_state
514                    .update_client_status(client_id, ClientStatus::Disconnected)
515                    .await;
516                let _ = status_tx.send("__UPDATE_UI__".to_string());
517                break;
518            }
519
520            // The query-result event goes to the model in its own task: a handler parked for
521            // a human must not block the next injected command.
522            if let Some(applied) = applied_for_follow_up {
523                let client = client.clone();
524                let llm_client = llm_client.clone();
525                let state = app_state.clone();
526                let tx = status_tx.clone();
527                let handle = tokio::spawn(async move {
528                    // An injected command is the operator starting a fresh chain, so it
529                    // begins at depth 0 rather than inheriting anything.
530                    Self::follow_up(applied, client, client_id, &llm_client, &state, &tx, 0).await;
531                });
532                app_state.register_client_task(client_id, handle).await;
533            }
534        }
535
536        app_state.remove_client_handle(client_id).await;
537        let _ = status_tx.send("__UPDATE_UI__".to_string());
538    }
539
540    /// Execute query and collect results
541    async fn execute_and_collect_query(
542        client: &mut TiberiusClient<tokio_util::compat::Compat<TcpStream>>,
543        query: &str,
544    ) -> Result<QueryResult> {
545        let mut stream = client.query(query, &[]).await?;
546
547        let mut columns = Vec::new();
548        let mut rows = Vec::new();
549        let rows_affected: u64;
550
551        // Collect column metadata
552        if let Some(cols) = stream.columns().await? {
553            for col in cols {
554                columns.push(json!({
555                    "name": col.name(),
556                    "type": format!("{:?}", col.column_type()),
557                }));
558            }
559        }
560
561        // Collect rows
562        while let Some(item) = stream.next().await {
563            match item {
564                Ok(QueryItem::Row(row)) => {
565                    let row_values = Self::row_to_json(&row)?;
566                    rows.push(row_values);
567                }
568                Ok(QueryItem::Metadata(_)) => {
569                    // Metadata already processed above
570                }
571                Err(e) => {
572                    return Err(e.into());
573                }
574            }
575        }
576
577        // For SELECT queries, rows_affected is typically the row count
578        rows_affected = rows.len() as u64;
579
580        Ok(QueryResult {
581            columns,
582            rows,
583            rows_affected,
584        })
585    }
586
587    /// Collect query results into JSON format (legacy - not used)
588    #[allow(dead_code)]
589    async fn collect_query_results(mut stream: tiberius::QueryStream<'_>) -> Result<QueryResult> {
590        let mut columns = Vec::new();
591        let mut rows = Vec::new();
592
593        // Collect column metadata
594        if let Some(cols) = stream.columns().await? {
595            for col in cols {
596                columns.push(json!({
597                    "name": col.name(),
598                    "type": format!("{:?}", col.column_type()),
599                }));
600            }
601        }
602
603        // Collect rows
604        while let Some(item) = stream.next().await {
605            match item {
606                Ok(QueryItem::Row(row)) => {
607                    let row_values = Self::row_to_json(&row)?;
608                    rows.push(row_values);
609                }
610                Ok(QueryItem::Metadata(_)) => {
611                    // Metadata already processed above
612                }
613                Err(e) => {
614                    return Err(e.into());
615                }
616            }
617        }
618
619        // For SELECT queries, rows_affected is typically 0 or the row count
620        // For INSERT/UPDATE/DELETE, tiberius would return this in metadata
621        // For simplicity, we'll use the row count as a proxy
622        let rows_affected = rows.len() as u64;
623
624        Ok(QueryResult {
625            columns,
626            rows,
627            rows_affected,
628        })
629    }
630
631    /// Convert a tiberius Row to JSON array
632    fn row_to_json(row: &Row) -> Result<serde_json::Value> {
633        let mut values = Vec::new();
634
635        for i in 0..row.len() {
636            let value = if let Ok(Some(s)) = row.try_get::<&str, _>(i) {
637                json!(s)
638            } else if let Ok(Some(n)) = row.try_get::<i32, _>(i) {
639                json!(n)
640            } else if let Ok(Some(n)) = row.try_get::<i64, _>(i) {
641                json!(n)
642            } else if let Ok(Some(b)) = row.try_get::<bool, _>(i) {
643                json!(b)
644            } else if let Ok(Some(f)) = row.try_get::<f32, _>(i) {
645                json!(f)
646            } else if let Ok(Some(f)) = row.try_get::<f64, _>(i) {
647                json!(f)
648            } else {
649                // NULL or unsupported type
650                json!(null)
651            };
652
653            values.push(value);
654        }
655
656        Ok(json!(values))
657    }
658}
659
660/// Query result structure
661struct QueryResult {
662    columns: Vec<serde_json::Value>,
663    rows: Vec<serde_json::Value>,
664    rows_affected: u64,
665}