feat: rescope to AI-first DB harness with multi-DB chat agent

Removes enterprise/DBA features and replaces the marginal AI bar with a
central chat agent that has progressive-discovery tools, cross-session
memory, saved-query reuse, and inline result actions. Adds ClickHouse
support alongside PostgreSQL/Greenplum.

Cleanup
- Drop ~10k LOC of advanced features: Docker, Snapshots, Validation,
  Index Advisor, Role/User Management, Data Generator, ERD, Lookup.
- Trim deps: drop @xyflow/react, dagre, @types/dagre; cut tokio features
  to rt-multi-thread/sync/time/net/macros.
- Remove unused TuskError variants and dead helpers (topological_sort,
  invalidate_schema_cache).

Multi-DB (PostgreSQL + ClickHouse)
- New src-tauri/src/db/ module: ChClient (HTTP-based, reuses reqwest),
  sql_guard (cross-flavor read-only whitelist with 8 tests).
- ConnectionConfig gains db_flavor and secure fields with serde defaults
  for backwards-compatible connections.json.
- All connection/query/schema/data commands dispatch by flavor; CH
  covers connect, execute_query, list_databases/schemas/tables/views/
  columns/completion_schema, paginated table fetch.
- Frontend: dbCapabilities matrix, ConnectionDialog engine selector
  with port auto-swap and HTTPS toggle, SqlEditor switches to
  StandardSQL dialect for CH, TableDataView surfaces CH connections as
  read-only.

AI-first chat agent
- New src/components/chat/ panel with composer, message rendering,
  collapsible tool-call/result blocks, top-level ErrorBoundary.
- Backend agent loop in commands/chat.rs with strict-JSON tool
  protocol. Nine tools: list_databases, list_tables, get_columns,
  switch_database, run_query, remember, save_query, find_queries, final.
  Forgiving parser accepts both flat and nested-input shapes.
- Compressed history: only the last 4 run_query results carry sample
  rows (≤10, cells truncated to 200 chars) into LLM context; older
  results marked omitted.
- System prompt uses lite OVERVIEW (DB list + active-DB tables only)
  instead of full DDL — schema details are loaded on demand via
  get_columns. CH OVERVIEW shows cross-DB tables since CH allows
  db.table queries.

Cross-session memory (F1)
- Per-connection markdown file at app_data_dir/memory/<connection_id>.md,
  16KB cap with oldest-block eviction. Agent appends via remember()
  tool; the file is injected into LEARNED NOTES section of every system
  prompt.
- New Memory sidebar tab with editable textarea, badge for note count,
  empty-state with template. Edits picked up on the next agent turn.

Saved-query reuse (F2)
- Tools save_query and find_queries scoped to current connection.
  save_query attaches a UUID + timestamp; find_queries returns top 10
  matches with SQL preview ≤500 chars.
- Storage shared with the sidebar Saved panel.

Inline result actions (F3)
- run_query result block in chat gets Open-full (90vw × 80vh modal with
  full ResultsTable, no row cap) and Export (reuses ExportDialog for
  CSV/JSON via existing exportCsv/exportJson commands).

Verification
- cargo check clean, zero warnings.
- cargo test --lib: 50 pass (20 chat parser + 4 memory + 8 sql_guard +
  6 clean_sql + 12 escape_ident).
- npx tsc --noEmit clean.
- npx vitest run: 20 pass.
This commit is contained in:
2026-05-06 19:30:44 +03:00
parent 652937f7f5
commit 4f7afc17f4
89 changed files with 4151 additions and 10756 deletions

View File

@@ -1,95 +1,11 @@
use std::collections::{HashMap, HashSet, VecDeque};
pub fn escape_ident(name: &str) -> String {
format!("\"{}\"", name.replace('"', "\"\""))
}
/// Topological sort of tables based on foreign key dependencies.
/// Returns tables in insertion order: parents before children.
pub fn topological_sort_tables(
fk_edges: &[(String, String, String, String)], // (schema, table, ref_schema, ref_table)
target_tables: &[(String, String)],
) -> Vec<(String, String)> {
let mut graph: HashMap<(String, String), HashSet<(String, String)>> = HashMap::new();
let mut in_degree: HashMap<(String, String), usize> = HashMap::new();
// Initialize all target tables
for t in target_tables {
graph.entry(t.clone()).or_default();
in_degree.entry(t.clone()).or_insert(0);
}
let target_set: HashSet<(String, String)> = target_tables.iter().cloned().collect();
// Build edges: parent -> child (child depends on parent)
for (schema, table, ref_schema, ref_table) in fk_edges {
let child = (schema.clone(), table.clone());
let parent = (ref_schema.clone(), ref_table.clone());
if child == parent {
continue; // self-referencing
}
if !target_set.contains(&child) || !target_set.contains(&parent) {
continue;
}
if graph
.entry(parent.clone())
.or_default()
.insert(child.clone())
{
*in_degree.entry(child).or_insert(0) += 1;
}
}
// Kahn's algorithm
let mut initial: Vec<(String, String)> = in_degree
.iter()
.filter(|(_, &deg)| deg == 0)
.map(|(k, _)| k.clone())
.collect();
initial.sort(); // deterministic order
let mut queue: VecDeque<(String, String)> = VecDeque::from(initial);
let mut result = Vec::new();
while let Some(node) = queue.pop_front() {
result.push(node.clone());
if let Some(neighbors) = graph.get(&node) {
let mut new_ready: Vec<(String, String)> = neighbors
.iter()
.filter(|neighbor| {
if let Some(deg) = in_degree.get_mut(*neighbor) {
*deg -= 1;
*deg == 0
} else {
false
}
})
.cloned()
.collect();
new_ready.sort();
queue.extend(new_ready);
}
}
// Add any remaining tables (cycles) at the end
for t in target_tables {
if !result.contains(t) {
result.push(t.clone());
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
// ── escape_ident ──────────────────────────────────────────
#[test]
fn escape_ident_simple_name() {
assert_eq!(escape_ident("users"), "\"users\"");
@@ -149,70 +65,4 @@ mod tests {
fn escape_ident_newline() {
assert_eq!(escape_ident("a\nb"), "\"a\nb\"");
}
// ── topological_sort_tables ───────────────────────────────
#[test]
fn topo_sort_no_edges() {
let tables = vec![("public".into(), "b".into()), ("public".into(), "a".into())];
let result = topological_sort_tables(&[], &tables);
assert_eq!(result.len(), 2);
assert!(result.contains(&("public".into(), "a".into())));
assert!(result.contains(&("public".into(), "b".into())));
}
#[test]
fn topo_sort_simple_dependency() {
let edges = vec![(
"public".into(),
"orders".into(),
"public".into(),
"users".into(),
)];
let tables = vec![
("public".into(), "orders".into()),
("public".into(), "users".into()),
];
let result = topological_sort_tables(&edges, &tables);
let user_pos = result.iter().position(|t| t.1 == "users").unwrap();
let order_pos = result.iter().position(|t| t.1 == "orders").unwrap();
assert!(user_pos < order_pos, "users must come before orders");
}
#[test]
fn topo_sort_self_reference() {
let edges = vec![(
"public".into(),
"employees".into(),
"public".into(),
"employees".into(),
)];
let tables = vec![("public".into(), "employees".into())];
let result = topological_sort_tables(&edges, &tables);
assert_eq!(result.len(), 1);
}
#[test]
fn topo_sort_cycle() {
let edges = vec![
("public".into(), "a".into(), "public".into(), "b".into()),
("public".into(), "b".into(), "public".into(), "a".into()),
];
let tables = vec![("public".into(), "a".into()), ("public".into(), "b".into())];
let result = topological_sort_tables(&edges, &tables);
assert_eq!(result.len(), 2);
}
#[test]
fn topo_sort_edge_outside_target_set_ignored() {
let edges = vec![(
"public".into(),
"orders".into(),
"public".into(),
"external".into(),
)];
let tables = vec![("public".into(), "orders".into())];
let result = topological_sort_tables(&edges, &tables);
assert_eq!(result.len(), 1);
}
}