Skip to main content

netget/client/postgresql/
mod.rs

1//! PostgreSQL client implementation
2pub mod actions;
3
4pub use actions::PostgresqlClientProtocol;
5
6use anyhow::{Context, Result};
7use std::net::SocketAddr;
8use std::sync::Arc;
9use tokio::sync::mpsc;
10use tracing::{error, info, trace, warn};
11
12use crate::client::llm_budget::call_llm_for_client;
13use crate::client::postgresql::actions::{
14    POSTGRESQL_CLIENT_CONNECTED_EVENT, POSTGRESQL_CLIENT_QUERY_RESULT_EVENT,
15};
16use crate::llm::actions::client_trait::{Client, ClientActionResult};
17use crate::llm::ollama_client::OllamaClient;
18use crate::llm::ClientLlmResult;
19use crate::protocol::Event;
20use crate::state::app_state::AppState;
21use crate::state::client_handles::{ClientCommand, ClientSendOutcome};
22use crate::state::{ClientId, ClientStatus};
23
24/// What one executed action did to the PostgreSQL connection.
25enum Applied {
26    /// A query ran; the rows are the JSON result set reported back to the model.
27    Query {
28        query: String,
29        rows: Vec<serde_json::Value>,
30    },
31    /// The session should end.
32    Disconnect,
33    /// The action executed but touched the connection in no way.
34    Nothing(&'static str),
35}
36
37/// How many action -> query -> result -> action turns one client will take before stopping.
38///
39/// The chain is genuinely self-referential, so it needs a bound rather than silence: a model
40/// that answers every result with another query would otherwise run forever. Four covers
41/// connect → create → insert → select → react to the rows, and a runaway costs five LLM calls
42/// rather than an unbounded number. The MySQL client uses the same bound.
43const MAX_FOLLOWUP_DEPTH: usize = 4;
44
45/// PostgreSQL client that connects to a PostgreSQL server
46pub struct PostgresqlClient;
47
48impl PostgresqlClient {
49    /// Connect to a PostgreSQL server with integrated LLM actions
50    pub async fn connect_with_llm_actions(
51        remote_addr: String,
52        llm_client: OllamaClient,
53        app_state: Arc<AppState>,
54        status_tx: mpsc::UnboundedSender<String>,
55        client_id: ClientId,
56        startup_params: Option<crate::protocol::StartupParams>,
57    ) -> Result<SocketAddr> {
58        // Extract connection parameters from startup_params if provided
59        let (database, user, password) = if let Some(params) = &startup_params {
60            let database = params
61                .get_optional_string("database")?
62                .unwrap_or_else(|| "postgres".to_string());
63            let user = params
64                .get_optional_string("user")?
65                .unwrap_or_else(|| "postgres".to_string());
66            let password = params
67                .get_optional_string("password")?
68                .unwrap_or_else(|| "".to_string());
69            (database, user, password)
70        } else {
71            (
72                "postgres".to_string(),
73                "postgres".to_string(),
74                "".to_string(),
75            )
76        };
77
78        // Build connection string. `remote_addr` is NetGet's "host:port"; a libpq keyword
79        // string has no such form — `host=127.0.0.1:5433` is read as a *hostname* containing
80        // a colon and resolution fails, so the port has to be split out into its own keyword.
81        let (host, port) = match remote_addr.rsplit_once(':') {
82            Some((h, p)) => match p.parse::<u16>() {
83                Ok(p) => (h.to_string(), p),
84                Err(_) => (remote_addr.clone(), 5432),
85            },
86            None => (remote_addr.clone(), 5432),
87        };
88        let conn_str = if password.is_empty() {
89            format!(
90                "host={} port={} user={} dbname={}",
91                host, port, user, database
92            )
93        } else {
94            format!(
95                "host={} port={} user={} password={} dbname={}",
96                host, port, user, password, database
97            )
98        };
99
100        info!(
101            "PostgreSQL client {} connecting to {} (user={}, database={})",
102            client_id, remote_addr, user, database
103        );
104
105        // Connect to PostgreSQL server
106        let (client, connection) = tokio_postgres::connect(&conn_str, tokio_postgres::NoTls)
107            .await
108            .context(format!(
109                "Failed to connect to PostgreSQL at {}",
110                remote_addr
111            ))?;
112
113        // Get the local address from the connection's underlying socket
114        // Note: tokio-postgres doesn't expose local_addr directly, so we parse from remote_addr
115        let local_addr: SocketAddr = format!("{}:0", host)
116            .parse()
117            .unwrap_or_else(|_| "127.0.0.1:0".parse().unwrap());
118
119        info!(
120            "PostgreSQL client {} connected to {}",
121            client_id, remote_addr
122        );
123
124        // Update client state
125        app_state
126            .update_client_status(client_id, ClientStatus::Connected)
127            .await;
128        let _ = status_tx.send(format!(
129            "[CLIENT] PostgreSQL client {} connected",
130            client_id
131        ));
132        let _ = status_tx.send("__UPDATE_UI__".to_string());
133
134        // Spawn connection task
135        let status_tx_clone = status_tx.clone();
136        let connection_handle = tokio::spawn(async move {
137            if let Err(e) = connection.await {
138                error!("PostgreSQL client {} connection error: {}", client_id, e);
139                let _ = status_tx_clone.send(format!(
140                    "[CLIENT] PostgreSQL client {} connection error: {}",
141                    client_id, e
142                ));
143            }
144        });
145        app_state
146            .register_client_task(client_id, connection_handle)
147            .await;
148
149        // The driver's `Client` is shared: the connected-event LLM task and the injected
150        // command loop both issue queries through it. Holding it in the command loop is
151        // also what keeps the session alive for a client with no instruction.
152        let client_arc = Arc::new(client);
153        let protocol = Arc::new(PostgresqlClientProtocol::new());
154
155        // Command channel for injected actions (the dashboard's [ send ]).
156        // Registered BEFORE the connected-event LLM call, which a manual `*` rule can park
157        // for minutes — the operator must be able to reach the client while it waits.
158        let command_rx =
159            crate::client::command_support::register_command_channel(&app_state, client_id).await;
160        let cmd_task = tokio::spawn(Self::command_loop(
161            command_rx,
162            protocol.clone(),
163            client_arc.clone(),
164            client_id,
165            llm_client.clone(),
166            app_state.clone(),
167            status_tx.clone(),
168        ));
169        app_state.register_client_task(client_id, cmd_task).await;
170
171        // Send connected event to LLM
172        if let Some(instruction) = app_state.get_instruction_for_client(client_id).await {
173            let event = Event::new(
174                &POSTGRESQL_CLIENT_CONNECTED_EVENT,
175                serde_json::json!({
176                    "remote_addr": remote_addr,
177                    "database": database,
178                    "user": user,
179                }),
180            );
181
182            let memory = app_state
183                .get_memory_for_client(client_id)
184                .await
185                .unwrap_or_default();
186
187            let protocol_clone = protocol.clone();
188            let client_arc_clone = client_arc.clone();
189            let llm_client_clone = llm_client.clone();
190            let app_state_clone = app_state.clone();
191            let status_tx_clone = status_tx.clone();
192
193            let task_registrar = app_state.clone();
194            let handle = tokio::spawn(async move {
195                match call_llm_for_client(
196                    &llm_client_clone,
197                    &app_state_clone,
198                    client_id.to_string(),
199                    &instruction,
200                    &memory,
201                    Some(&event),
202                    protocol_clone.as_ref(),
203                    &status_tx_clone,
204                )
205                .await
206                {
207                    Ok(ClientLlmResult {
208                        actions,
209                        memory_updates,
210                    }) => {
211                        // Update memory
212                        if let Some(mem) = memory_updates {
213                            app_state_clone.set_memory_for_client(client_id, mem).await;
214                        }
215
216                        // Execute actions
217                        for action in actions {
218                            if let Err(e) = Self::execute_llm_action(
219                                client_id,
220                                action,
221                                &protocol_clone,
222                                &client_arc_clone,
223                                &app_state_clone,
224                                &llm_client_clone,
225                                &status_tx_clone,
226                                0,
227                            )
228                            .await
229                            {
230                                error!("Error executing PostgreSQL action: {}", e);
231                            }
232                        }
233                    }
234                    Err(e) => {
235                        error!("LLM error for PostgreSQL client {}: {}", client_id, e);
236                    }
237                }
238            });
239            task_registrar.register_client_task(client_id, handle).await;
240        }
241
242        Ok(local_addr)
243    }
244
245    /// Drain injected commands until the channel closes (the client was removed or stopped)
246    /// or an injected `disconnect` ends the session.
247    ///
248    /// `tokio_postgres` owns the socket, so `execute_query` yields
249    /// `ClientActionResult::Custom` and the generic split-stream arm cannot serve it; the
250    /// effect goes through [`Self::apply_action`], the same function the LLM path uses.
251    #[allow(clippy::too_many_arguments)]
252    async fn command_loop(
253        mut command_rx: mpsc::Receiver<ClientCommand>,
254        protocol: Arc<PostgresqlClientProtocol>,
255        pg_client: Arc<tokio_postgres::Client>,
256        client_id: ClientId,
257        llm_client: OllamaClient,
258        app_state: Arc<AppState>,
259        status_tx: mpsc::UnboundedSender<String>,
260    ) {
261        use crate::llm::actions::protocol_trait::Protocol;
262        use crate::state::AccessLogOwner;
263
264        while let Some(command) = command_rx.recv().await {
265            let action = command.action.clone();
266
267            let mut follow_up: Option<(String, Vec<serde_json::Value>)> = None;
268            let outcome = match protocol.execute_action(action.clone()) {
269                Err(e) => Ok(ClientSendOutcome::Rejected {
270                    error: e.to_string(),
271                }),
272                Ok(result) => match Self::apply_action(result, &pg_client, client_id).await {
273                    Err(e) => Err(e),
274                    Ok(Applied::Disconnect) => Ok(ClientSendOutcome::Disconnected),
275                    Ok(Applied::Nothing(what)) => Ok(ClientSendOutcome::Executed {
276                        detail: what.to_string(),
277                    }),
278                    Ok(Applied::Query { query, rows }) => {
279                        let detail = format!(
280                            "query executed by tokio_postgres ({} rows); \
281                             no byte count — the driver owns the socket: {}",
282                            rows.len(),
283                            crate::utils::truncate::truncate_for_log(&query, 80)
284                        );
285                        follow_up = Some((query, rows));
286                        Ok(ClientSendOutcome::Executed { detail })
287                    }
288                },
289            };
290
291            let outcome_json = match &outcome {
292                Ok(outcome) => serde_json::to_value(outcome).unwrap_or(serde_json::Value::Null),
293                Err(e) => serde_json::json!({"error": e.to_string()}),
294            };
295            app_state
296                .record_access_log(
297                    AccessLogOwner::Client(client_id.as_u32()),
298                    protocol.protocol_name(),
299                    None,
300                    "injected_action",
301                    action,
302                    vec![outcome_json],
303                )
304                .await;
305
306            let disconnect = matches!(outcome, Ok(ClientSendOutcome::Disconnected));
307            if let Err(e) = &outcome {
308                error!(
309                    "PostgreSQL client {} injected action failed: {}",
310                    client_id, e
311                );
312                let _ = status_tx.send(format!(
313                    "[WARN] Client {} injected action failed: {}",
314                    client_id, e
315                ));
316            }
317            let _ = status_tx.send("__UPDATE_UI__".to_string());
318            crate::client::command_support::reply(command, outcome);
319
320            if disconnect {
321                Self::mark_disconnected(client_id, &app_state, &status_tx).await;
322                break;
323            }
324
325            // The model sees the result of a user-injected query exactly as it sees one it
326            // asked for — after the reply, so the dashboard is not held for an LLM round-trip.
327            if let Some((query, rows)) = follow_up {
328                Self::report_query_result(
329                    client_id,
330                    &query,
331                    rows,
332                    &pg_client,
333                    &protocol,
334                    &app_state,
335                    &llm_client,
336                    &status_tx,
337                    0,
338                )
339                .await;
340            }
341        }
342
343        app_state.remove_client_handle(client_id).await;
344        let _ = status_tx.send("__UPDATE_UI__".to_string());
345    }
346
347    /// Execute one action produced by the model and, if it ran a query, report the rows back.
348    ///
349    /// Returns an explicitly boxed `+ Send` future rather than being an `async fn`: this and
350    /// [`Self::report_query_result`] call each other (a query's rows go to the model, whose
351    /// answer may be another query), and naming the type is what lets the compiler prove
352    /// `Send` for the cycle — required because the chain runs inside a `tokio::spawn`. The
353    /// cycle is bounded by [`MAX_FOLLOWUP_DEPTH`].
354    #[allow(clippy::too_many_arguments)]
355    fn execute_llm_action<'a>(
356        client_id: ClientId,
357        action: serde_json::Value,
358        protocol: &'a Arc<PostgresqlClientProtocol>,
359        pg_client: &'a Arc<tokio_postgres::Client>,
360        app_state: &'a Arc<AppState>,
361        llm_client: &'a OllamaClient,
362        status_tx: &'a mpsc::UnboundedSender<String>,
363        depth: usize,
364    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<()>> + Send + 'a>> {
365        Box::pin(async move {
366            match Self::apply_action(protocol.execute_action(action)?, pg_client, client_id).await {
367                Ok(Applied::Query { query, rows }) => {
368                    Self::report_query_result(
369                        client_id, &query, rows, pg_client, protocol, app_state, llm_client,
370                        status_tx, depth,
371                    )
372                    .await;
373                }
374                Ok(Applied::Disconnect) => {
375                    info!("PostgreSQL client {} disconnecting", client_id);
376                    Self::mark_disconnected(client_id, app_state, status_tx).await;
377                }
378                Ok(Applied::Nothing(what)) => {
379                    trace!(
380                        "PostgreSQL client {} action had no effect: {}",
381                        client_id,
382                        what
383                    );
384                }
385                Err(e) => {
386                    error!("PostgreSQL client {} query error: {}", client_id, e);
387                    let _ = status_tx.send(format!(
388                        "[CLIENT] PostgreSQL client {} query error: {}",
389                        client_id, e
390                    ));
391                }
392            }
393            Ok(())
394        })
395    }
396
397    /// Run one executed action against the connection. Shared by the LLM path and injected
398    /// commands so the query path exists exactly once.
399    async fn apply_action(
400        result: ClientActionResult,
401        pg_client: &Arc<tokio_postgres::Client>,
402        client_id: ClientId,
403    ) -> Result<Applied> {
404        match result {
405            ClientActionResult::Custom { name, data } if name == "pg_query" => {
406                let query = data
407                    .get("query")
408                    .and_then(|v| v.as_str())
409                    .context("Missing 'query' in pg_query action data")?
410                    .to_string();
411
412                trace!("PostgreSQL client {} executing: {}", client_id, query);
413
414                // `query` takes `&self`: tokio_postgres pipelines requests over its own
415                // connection task, so the client is shared by `Arc` and no lock is held
416                // across the round-trip to the server.
417                let rows = pg_client
418                    .query(query.as_str(), &[])
419                    .await
420                    .context("Failed to execute query")?;
421
422                let result = rows
423                    .iter()
424                    .map(|row| {
425                        let mut obj = serde_json::Map::new();
426                        for (idx, col) in row.columns().iter().enumerate() {
427                            obj.insert(col.name().to_string(), pg_cell_to_json(row, idx));
428                        }
429                        serde_json::Value::Object(obj)
430                    })
431                    .collect::<Vec<_>>();
432
433                info!(
434                    "PostgreSQL client {} query returned {} rows",
435                    client_id,
436                    result.len()
437                );
438
439                Ok(Applied::Query {
440                    query,
441                    rows: result,
442                })
443            }
444            ClientActionResult::Custom { name, .. } => {
445                trace!(
446                    "PostgreSQL client {} ignoring custom result '{}'",
447                    client_id,
448                    name
449                );
450                Ok(Applied::Nothing("custom result not handled by this client"))
451            }
452            ClientActionResult::Disconnect => Ok(Applied::Disconnect),
453            ClientActionResult::WaitForMore => Ok(Applied::Nothing("wait_for_more")),
454            ClientActionResult::NoAction => Ok(Applied::Nothing("no_action")),
455            ClientActionResult::SendData(_) => Ok(Applied::Nothing(
456                "send_data is not meaningful for a tokio_postgres connection",
457            )),
458            ClientActionResult::Multiple(_) => Ok(Applied::Nothing(
459                "multiple results not handled by this client",
460            )),
461        }
462    }
463
464    /// Raise `postgresql_query_result` with a query's rows and run whatever the model answers.
465    ///
466    /// The answer is **executed** through [`Self::execute_llm_action`], so a follow-up query's
467    /// own rows come back to the model in turn: connect → `CREATE TABLE` → `INSERT` → `SELECT`
468    /// → react to the rows is a chain of four results, each decided by the model after it has
469    /// seen the previous one. The chain stops at [`MAX_FOLLOWUP_DEPTH`]; past it the model's
470    /// actions are dropped with a warning rather than looping.
471    #[allow(clippy::too_many_arguments)]
472    async fn report_query_result(
473        client_id: ClientId,
474        query: &str,
475        rows: Vec<serde_json::Value>,
476        pg_client: &Arc<tokio_postgres::Client>,
477        protocol: &Arc<PostgresqlClientProtocol>,
478        app_state: &Arc<AppState>,
479        llm_client: &OllamaClient,
480        status_tx: &mpsc::UnboundedSender<String>,
481        depth: usize,
482    ) {
483        let Some(instruction) = app_state.get_instruction_for_client(client_id).await else {
484            return;
485        };
486
487        let event = Event::new(
488            &POSTGRESQL_CLIENT_QUERY_RESULT_EVENT,
489            serde_json::json!({
490                "query": query,
491                "rows": rows,
492                "row_count": rows.len(),
493            }),
494        );
495
496        let memory = app_state
497            .get_memory_for_client(client_id)
498            .await
499            .unwrap_or_default();
500
501        // `match`, not `if let Ok(..)`: a client that goes quiet because its model failed
502        // should say so.
503        let (actions, memory_updates) = match call_llm_for_client(
504            llm_client,
505            app_state,
506            client_id.to_string(),
507            &instruction,
508            &memory,
509            Some(&event),
510            protocol.as_ref(),
511            status_tx,
512        )
513        .await
514        {
515            Ok(ClientLlmResult {
516                actions,
517                memory_updates,
518            }) => (actions, memory_updates),
519            Err(e) => {
520                error!(
521                    "LLM error for PostgreSQL client {} on a query result: {}",
522                    client_id, e
523                );
524                return;
525            }
526        };
527        if let Some(mem) = memory_updates {
528            app_state.set_memory_for_client(client_id, mem).await;
529        }
530        if actions.is_empty() {
531            return;
532        }
533        if depth >= MAX_FOLLOWUP_DEPTH {
534            warn!(
535                "PostgreSQL client {} reached the follow-up depth bound ({}); dropping {} \
536                 action(s) rather than looping",
537                client_id,
538                MAX_FOLLOWUP_DEPTH,
539                actions.len()
540            );
541            return;
542        }
543        for action in actions {
544            if let Err(e) = Self::execute_llm_action(
545                client_id,
546                action,
547                protocol,
548                pg_client,
549                app_state,
550                llm_client,
551                status_tx,
552                depth + 1,
553            )
554            .await
555            {
556                error!(
557                    "PostgreSQL client {} rejected its own follow-up action: {}",
558                    client_id, e
559                );
560            }
561        }
562    }
563
564    /// Mark the client disconnected **and** stop offering to send on it.
565    ///
566    /// Dropping the command handle closes the channel, which ends `command_loop`; without it
567    /// an LLM-requested `disconnect` left the client drawn as `Disconnected` while the loop
568    /// still ran and the dashboard still offered `[ send ]`. Only the *injected* disconnect
569    /// path ended the loop, because it breaks out of it directly.
570    ///
571    /// `tokio_postgres::Client` closes when the last handle drops, and it is an `Arc` shared
572    /// with `command_loop`, so ending that loop is what releases it.
573    async fn mark_disconnected(
574        client_id: ClientId,
575        app_state: &Arc<AppState>,
576        status_tx: &mpsc::UnboundedSender<String>,
577    ) {
578        app_state.remove_client_handle(client_id).await;
579        app_state
580            .update_client_status(client_id, ClientStatus::Disconnected)
581            .await;
582        let _ = status_tx.send(format!(
583            "[CLIENT] PostgreSQL client {} disconnected",
584            client_id
585        ));
586        let _ = status_tx.send("__UPDATE_UI__".to_string());
587    }
588}
589
590/// Read one cell as JSON without ever panicking.
591///
592/// This used to be `let value: Option<String> = row.get(idx)`. `tokio_postgres::Row::get`
593/// **panics** when the column's type has no `FromSql` impl for the requested Rust type, and
594/// `Option<String>` only accepts the text-ish types — so a single `int4`, `bool` or `float8`
595/// column panicked the whole query.
596///
597/// That panic happened inside a `tokio::spawn`, which swallows it: nothing was logged, the
598/// client stayed `Connected`, no `postgresql_query_result` event was raised, and the model
599/// simply never heard back. It is the exact failure shape the root CLAUDE.md records for
600/// `block_on` in usb-msc and SMB — the task dies quietly and the peer waits forever.
601///
602/// `try_get` is the non-panicking form. Types are tried in declared order and the final
603/// fallback is JSON null, so an unknown OID costs one null cell rather than the query.
604fn pg_cell_to_json(row: &tokio_postgres::Row, idx: usize) -> serde_json::Value {
605    use tokio_postgres::types::Type;
606
607    macro_rules! try_as {
608        ($t:ty) => {
609            if let Ok(v) = row.try_get::<_, Option<$t>>(idx) {
610                return serde_json::json!(v);
611            }
612        };
613    }
614
615    match *row.columns()[idx].type_() {
616        Type::BOOL => try_as!(bool),
617        Type::INT2 => try_as!(i16),
618        Type::INT4 => try_as!(i32),
619        Type::INT8 => try_as!(i64),
620        Type::FLOAT4 => try_as!(f32),
621        Type::FLOAT8 => try_as!(f64),
622        _ => {}
623    }
624    // Text and everything text-shaped, then give up rather than panic.
625    try_as!(String);
626    serde_json::Value::Null
627}