pub mod actions;
pub use actions::PostgresqlClientProtocol;
use anyhow::{Context, Result};
use std::net::SocketAddr;
use std::sync::Arc;
use tokio::sync::mpsc;
use tracing::{error, info, trace, warn};
use crate::client::llm_budget::call_llm_for_client;
use crate::client::postgresql::actions::{
POSTGRESQL_CLIENT_CONNECTED_EVENT, POSTGRESQL_CLIENT_QUERY_RESULT_EVENT,
};
use crate::llm::actions::client_trait::{Client, ClientActionResult};
use crate::llm::ollama_client::OllamaClient;
use crate::llm::ClientLlmResult;
use crate::protocol::Event;
use crate::state::app_state::AppState;
use crate::state::client_handles::{ClientCommand, ClientSendOutcome};
use crate::state::{ClientId, ClientStatus};
enum Applied {
Query {
query: String,
rows: Vec<serde_json::Value>,
},
Disconnect,
Nothing(&'static str),
}
const MAX_FOLLOWUP_DEPTH: usize = 4;
pub struct PostgresqlClient;
impl PostgresqlClient {
pub async fn connect_with_llm_actions(
remote_addr: String,
llm_client: OllamaClient,
app_state: Arc<AppState>,
status_tx: mpsc::UnboundedSender<String>,
client_id: ClientId,
startup_params: Option<crate::protocol::StartupParams>,
) -> Result<SocketAddr> {
let (database, user, password) = if let Some(params) = &startup_params {
let database = params
.get_optional_string("database")?
.unwrap_or_else(|| "postgres".to_string());
let user = params
.get_optional_string("user")?
.unwrap_or_else(|| "postgres".to_string());
let password = params
.get_optional_string("password")?
.unwrap_or_else(|| "".to_string());
(database, user, password)
} else {
(
"postgres".to_string(),
"postgres".to_string(),
"".to_string(),
)
};
let (host, port) = match remote_addr.rsplit_once(':') {
Some((h, p)) => match p.parse::<u16>() {
Ok(p) => (h.to_string(), p),
Err(_) => (remote_addr.clone(), 5432),
},
None => (remote_addr.clone(), 5432),
};
let conn_str = if password.is_empty() {
format!(
"host={} port={} user={} dbname={}",
host, port, user, database
)
} else {
format!(
"host={} port={} user={} password={} dbname={}",
host, port, user, password, database
)
};
info!(
"PostgreSQL client {} connecting to {} (user={}, database={})",
client_id, remote_addr, user, database
);
let (client, connection) = tokio_postgres::connect(&conn_str, tokio_postgres::NoTls)
.await
.context(format!(
"Failed to connect to PostgreSQL at {}",
remote_addr
))?;
let local_addr: SocketAddr = format!("{}:0", host)
.parse()
.unwrap_or_else(|_| "127.0.0.1:0".parse().unwrap());
info!(
"PostgreSQL client {} connected to {}",
client_id, remote_addr
);
app_state
.update_client_status(client_id, ClientStatus::Connected)
.await;
let _ = status_tx.send(format!(
"[CLIENT] PostgreSQL client {} connected",
client_id
));
let _ = status_tx.send("__UPDATE_UI__".to_string());
let status_tx_clone = status_tx.clone();
let connection_handle = tokio::spawn(async move {
if let Err(e) = connection.await {
error!("PostgreSQL client {} connection error: {}", client_id, e);
let _ = status_tx_clone.send(format!(
"[CLIENT] PostgreSQL client {} connection error: {}",
client_id, e
));
}
});
app_state
.register_client_task(client_id, connection_handle)
.await;
let client_arc = Arc::new(client);
let protocol = Arc::new(PostgresqlClientProtocol::new());
let command_rx =
crate::client::command_support::register_command_channel(&app_state, client_id).await;
let cmd_task = tokio::spawn(Self::command_loop(
command_rx,
protocol.clone(),
client_arc.clone(),
client_id,
llm_client.clone(),
app_state.clone(),
status_tx.clone(),
));
app_state.register_client_task(client_id, cmd_task).await;
if let Some(instruction) = app_state.get_instruction_for_client(client_id).await {
let event = Event::new(
&POSTGRESQL_CLIENT_CONNECTED_EVENT,
serde_json::json!({
"remote_addr": remote_addr,
"database": database,
"user": user,
}),
);
let memory = app_state
.get_memory_for_client(client_id)
.await
.unwrap_or_default();
let protocol_clone = protocol.clone();
let client_arc_clone = client_arc.clone();
let llm_client_clone = llm_client.clone();
let app_state_clone = app_state.clone();
let status_tx_clone = status_tx.clone();
let task_registrar = app_state.clone();
let handle = tokio::spawn(async move {
match call_llm_for_client(
&llm_client_clone,
&app_state_clone,
client_id.to_string(),
&instruction,
&memory,
Some(&event),
protocol_clone.as_ref(),
&status_tx_clone,
)
.await
{
Ok(ClientLlmResult {
actions,
memory_updates,
}) => {
if let Some(mem) = memory_updates {
app_state_clone.set_memory_for_client(client_id, mem).await;
}
for action in actions {
if let Err(e) = Self::execute_llm_action(
client_id,
action,
&protocol_clone,
&client_arc_clone,
&app_state_clone,
&llm_client_clone,
&status_tx_clone,
0,
)
.await
{
error!("Error executing PostgreSQL action: {}", e);
}
}
}
Err(e) => {
error!("LLM error for PostgreSQL client {}: {}", client_id, e);
}
}
});
task_registrar.register_client_task(client_id, handle).await;
}
Ok(local_addr)
}
#[allow(clippy::too_many_arguments)]
async fn command_loop(
mut command_rx: mpsc::Receiver<ClientCommand>,
protocol: Arc<PostgresqlClientProtocol>,
pg_client: Arc<tokio_postgres::Client>,
client_id: ClientId,
llm_client: OllamaClient,
app_state: Arc<AppState>,
status_tx: mpsc::UnboundedSender<String>,
) {
use crate::llm::actions::protocol_trait::Protocol;
use crate::state::AccessLogOwner;
while let Some(command) = command_rx.recv().await {
let action = command.action.clone();
let mut follow_up: Option<(String, Vec<serde_json::Value>)> = None;
let outcome = match protocol.execute_action(action.clone()) {
Err(e) => Ok(ClientSendOutcome::Rejected {
error: e.to_string(),
}),
Ok(result) => match Self::apply_action(result, &pg_client, client_id).await {
Err(e) => Err(e),
Ok(Applied::Disconnect) => Ok(ClientSendOutcome::Disconnected),
Ok(Applied::Nothing(what)) => Ok(ClientSendOutcome::Executed {
detail: what.to_string(),
}),
Ok(Applied::Query { query, rows }) => {
let detail = format!(
"query executed by tokio_postgres ({} rows); \
no byte count — the driver owns the socket: {}",
rows.len(),
crate::utils::truncate::truncate_for_log(&query, 80)
);
follow_up = Some((query, rows));
Ok(ClientSendOutcome::Executed { detail })
}
},
};
let outcome_json = match &outcome {
Ok(outcome) => serde_json::to_value(outcome).unwrap_or(serde_json::Value::Null),
Err(e) => serde_json::json!({"error": e.to_string()}),
};
app_state
.record_access_log(
AccessLogOwner::Client(client_id.as_u32()),
protocol.protocol_name(),
None,
"injected_action",
action,
vec![outcome_json],
)
.await;
let disconnect = matches!(outcome, Ok(ClientSendOutcome::Disconnected));
if let Err(e) = &outcome {
error!(
"PostgreSQL client {} injected action failed: {}",
client_id, e
);
let _ = status_tx.send(format!(
"[WARN] Client {} injected action failed: {}",
client_id, e
));
}
let _ = status_tx.send("__UPDATE_UI__".to_string());
crate::client::command_support::reply(command, outcome);
if disconnect {
Self::mark_disconnected(client_id, &app_state, &status_tx).await;
break;
}
if let Some((query, rows)) = follow_up {
Self::report_query_result(
client_id,
&query,
rows,
&pg_client,
&protocol,
&app_state,
&llm_client,
&status_tx,
0,
)
.await;
}
}
app_state.remove_client_handle(client_id).await;
let _ = status_tx.send("__UPDATE_UI__".to_string());
}
#[allow(clippy::too_many_arguments)]
fn execute_llm_action<'a>(
client_id: ClientId,
action: serde_json::Value,
protocol: &'a Arc<PostgresqlClientProtocol>,
pg_client: &'a Arc<tokio_postgres::Client>,
app_state: &'a Arc<AppState>,
llm_client: &'a OllamaClient,
status_tx: &'a mpsc::UnboundedSender<String>,
depth: usize,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<()>> + Send + 'a>> {
Box::pin(async move {
match Self::apply_action(protocol.execute_action(action)?, pg_client, client_id).await {
Ok(Applied::Query { query, rows }) => {
Self::report_query_result(
client_id, &query, rows, pg_client, protocol, app_state, llm_client,
status_tx, depth,
)
.await;
}
Ok(Applied::Disconnect) => {
info!("PostgreSQL client {} disconnecting", client_id);
Self::mark_disconnected(client_id, app_state, status_tx).await;
}
Ok(Applied::Nothing(what)) => {
trace!(
"PostgreSQL client {} action had no effect: {}",
client_id,
what
);
}
Err(e) => {
error!("PostgreSQL client {} query error: {}", client_id, e);
let _ = status_tx.send(format!(
"[CLIENT] PostgreSQL client {} query error: {}",
client_id, e
));
}
}
Ok(())
})
}
async fn apply_action(
result: ClientActionResult,
pg_client: &Arc<tokio_postgres::Client>,
client_id: ClientId,
) -> Result<Applied> {
match result {
ClientActionResult::Custom { name, data } if name == "pg_query" => {
let query = data
.get("query")
.and_then(|v| v.as_str())
.context("Missing 'query' in pg_query action data")?
.to_string();
trace!("PostgreSQL client {} executing: {}", client_id, query);
let rows = pg_client
.query(query.as_str(), &[])
.await
.context("Failed to execute query")?;
let result = rows
.iter()
.map(|row| {
let mut obj = serde_json::Map::new();
for (idx, col) in row.columns().iter().enumerate() {
obj.insert(col.name().to_string(), pg_cell_to_json(row, idx));
}
serde_json::Value::Object(obj)
})
.collect::<Vec<_>>();
info!(
"PostgreSQL client {} query returned {} rows",
client_id,
result.len()
);
Ok(Applied::Query {
query,
rows: result,
})
}
ClientActionResult::Custom { name, .. } => {
trace!(
"PostgreSQL client {} ignoring custom result '{}'",
client_id,
name
);
Ok(Applied::Nothing("custom result not handled by this client"))
}
ClientActionResult::Disconnect => Ok(Applied::Disconnect),
ClientActionResult::WaitForMore => Ok(Applied::Nothing("wait_for_more")),
ClientActionResult::NoAction => Ok(Applied::Nothing("no_action")),
ClientActionResult::SendData(_) => Ok(Applied::Nothing(
"send_data is not meaningful for a tokio_postgres connection",
)),
ClientActionResult::Multiple(_) => Ok(Applied::Nothing(
"multiple results not handled by this client",
)),
}
}
#[allow(clippy::too_many_arguments)]
async fn report_query_result(
client_id: ClientId,
query: &str,
rows: Vec<serde_json::Value>,
pg_client: &Arc<tokio_postgres::Client>,
protocol: &Arc<PostgresqlClientProtocol>,
app_state: &Arc<AppState>,
llm_client: &OllamaClient,
status_tx: &mpsc::UnboundedSender<String>,
depth: usize,
) {
let Some(instruction) = app_state.get_instruction_for_client(client_id).await else {
return;
};
let event = Event::new(
&POSTGRESQL_CLIENT_QUERY_RESULT_EVENT,
serde_json::json!({
"query": query,
"rows": rows,
"row_count": rows.len(),
}),
);
let memory = app_state
.get_memory_for_client(client_id)
.await
.unwrap_or_default();
let (actions, memory_updates) = match call_llm_for_client(
llm_client,
app_state,
client_id.to_string(),
&instruction,
&memory,
Some(&event),
protocol.as_ref(),
status_tx,
)
.await
{
Ok(ClientLlmResult {
actions,
memory_updates,
}) => (actions, memory_updates),
Err(e) => {
error!(
"LLM error for PostgreSQL client {} on a query result: {}",
client_id, e
);
return;
}
};
if let Some(mem) = memory_updates {
app_state.set_memory_for_client(client_id, mem).await;
}
if actions.is_empty() {
return;
}
if depth >= MAX_FOLLOWUP_DEPTH {
warn!(
"PostgreSQL client {} reached the follow-up depth bound ({}); dropping {} \
action(s) rather than looping",
client_id,
MAX_FOLLOWUP_DEPTH,
actions.len()
);
return;
}
for action in actions {
if let Err(e) = Self::execute_llm_action(
client_id,
action,
protocol,
pg_client,
app_state,
llm_client,
status_tx,
depth + 1,
)
.await
{
error!(
"PostgreSQL client {} rejected its own follow-up action: {}",
client_id, e
);
}
}
}
async fn mark_disconnected(
client_id: ClientId,
app_state: &Arc<AppState>,
status_tx: &mpsc::UnboundedSender<String>,
) {
app_state.remove_client_handle(client_id).await;
app_state
.update_client_status(client_id, ClientStatus::Disconnected)
.await;
let _ = status_tx.send(format!(
"[CLIENT] PostgreSQL client {} disconnected",
client_id
));
let _ = status_tx.send("__UPDATE_UI__".to_string());
}
}
fn pg_cell_to_json(row: &tokio_postgres::Row, idx: usize) -> serde_json::Value {
use tokio_postgres::types::Type;
macro_rules! try_as {
($t:ty) => {
if let Ok(v) = row.try_get::<_, Option<$t>>(idx) {
return serde_json::json!(v);
}
};
}
match *row.columns()[idx].type_() {
Type::BOOL => try_as!(bool),
Type::INT2 => try_as!(i16),
Type::INT4 => try_as!(i32),
Type::INT8 => try_as!(i64),
Type::FLOAT4 => try_as!(f32),
Type::FLOAT8 => try_as!(f64),
_ => {}
}
try_as!(String);
serde_json::Value::Null
}