From b86f0d642bf1d71734cb2c39edadd47225466c48 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 13 Aug 2026 08:24:11 +0000 Subject: [PATCH 1/4] =?UTF-8?q?feat(oss):=20ship=20E2E=20fix=20proposal=20?= =?UTF-8?q?MVP=20(W1=E2=80=93W5=20+=20thin=20W3/W6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Close the OSS launch blockers from docs/oss-ux/E2E_FIX_PROPOSAL.md: - W2a/b: Postgres Brain scans all non-system schemas with qualified keys; coverage gate fails or NEEDS_ATTENTION instead of fake Complete 100% - W1: provisioner writes DEEPSQL_TOKEN_FILE, fail-loud provision, MCP auth probe + Agent boot banner, revoke cleans disk token - W4: Brain stage enum sync, stepper advances to Add context, jobs collapsed - W5: index enrichment + skip UNINDEXED_* on PK/UK/TRUE_KEY - W3 thin: /onboarding routed + redirect when no connections; payload fix - W6: DeepSQL title, generic Agent suggestions, dbType canonicalize Co-authored-by: Venkat SF --- .../controller/AgentBridgeController.java | 35 +- .../java/com/dbaagent/model/InitStage.java | 9 +- .../PostgresIntrospectionProvider.java | 254 ++++++++------ .../dbaagent/service/AgentBridgeService.java | 176 +++++++++- .../dbaagent/service/CredentialService.java | 10 +- .../service/QueryExecutorService.java | 77 +++-- .../service/SchemaIntrospectionService.java | 12 +- .../keycolumn/KeyColumnAnalysisService.java | 102 ++++-- .../scheduler/BrainInitSchedulerService.java | 2 +- .../scheduler/BrainInitStageExecutor.java | 103 +++++- .../V110__brain_init_needs_attention.sql | 34 ++ .../CredentialServiceTelemetryTest.java | 8 +- index.html | 2 +- scripts/local-agent-provisioner.py | 86 ++++- scripts/self-host/setup-agent.sh | 13 + src/App.jsx | 14 +- src/components/AgentChat/AgentChatPanel.jsx | 33 +- .../company-knowledge/BackgroundJobsTab.jsx | 326 +++++++++++------- .../CompanyKnowledgePanel.jsx | 31 +- src/components/onboarding/StepDatabase.jsx | 16 +- src/hooks/useAuth.jsx | 22 +- src/lib/api/agentClient.js | 7 +- src/lib/queryKeys.js | 1 + src/lib/stores/useCompanyKnowledgeStore.js | 19 +- src/pages/Login.jsx | 25 +- src/pages/Onboarding.jsx | 21 +- 26 files changed, 1067 insertions(+), 371 deletions(-) create mode 100644 backend/src/main/resources/db/migration/V110__brain_init_needs_attention.sql diff --git a/backend/src/main/java/com/dbaagent/controller/AgentBridgeController.java b/backend/src/main/java/com/dbaagent/controller/AgentBridgeController.java index 8d05c6e..0c6dd65 100644 --- a/backend/src/main/java/com/dbaagent/controller/AgentBridgeController.java +++ b/backend/src/main/java/com/dbaagent/controller/AgentBridgeController.java @@ -5,10 +5,14 @@ import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import java.util.HashMap; import java.util.Map; /** @@ -20,6 +24,8 @@ @RequestMapping("/agent") @RequiredArgsConstructor public class AgentBridgeController { + private static final Logger log = LoggerFactory.getLogger(AgentBridgeController.class); + private final AccessControlService accessControlService; private final AgentBridgeService agentBridgeService; @@ -33,8 +39,33 @@ public ResponseEntity> session( String username = accessControlService.requireCurrentUsername(); String token = extractToken(request); String connectionId = body == null ? null : asString(body.get("connectionId")); - String profile = agentBridgeService.ensureProfile(username, token, connectionId); - return ResponseEntity.ok(Map.of("profile", profile, "username", username)); + + AgentBridgeService.ProfileBootstrap bootstrap; + try { + bootstrap = agentBridgeService.ensureProfile(username, token, connectionId); + } catch (AgentBridgeService.ProvisioningException e) { + log.warn("Agent session bootstrap failed for {}: {}", username, e.getMessage()); + return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body(Map.of( + "error", "agent_provisioning_failed", + "message", "Could not provision the DeepSQL Agent for this user. " + + "Check that the agent provisioner is running and reachable." + )); + } + + // Boot health check: probe the exact token just provisioned against this + // backend's own API before telling the UI it's safe to chat. Without + // this, an expired/misrouted token surfaces only after several failed + // tool calls deep into a conversation (W1 "fail loud, early"). + Map response = new HashMap<>(); + response.put("profile", bootstrap.profile()); + response.put("username", username); + boolean mcpAuthOk = agentBridgeService.probeMcpAuth(bootstrap.token()); + response.put("mcpAuthOk", mcpAuthOk); + if (!mcpAuthOk) { + response.put("mcpAuthError", "The DeepSQL Agent could not authenticate against this API with its " + + "provisioned token. Reconnect or check the Agent runtime."); + } + return ResponseEntity.ok(response); } private String extractToken(HttpServletRequest request) { diff --git a/backend/src/main/java/com/dbaagent/model/InitStage.java b/backend/src/main/java/com/dbaagent/model/InitStage.java index f1b37ad..3cceca0 100644 --- a/backend/src/main/java/com/dbaagent/model/InitStage.java +++ b/backend/src/main/java/com/dbaagent/model/InitStage.java @@ -11,11 +11,16 @@ public enum InitStage { RAG_EMBEDDING, BRAIN_ANALYSIS, SEMANTIC_MODELING, + /** + * Schema coverage incomplete — Brain must not claim Complete 100%. + * Surfaced when indexed base tables are far below live user tables (W2b). + */ + NEEDS_ATTENTION, COMPLETED, FAILED; public boolean isTerminal() { - return this == COMPLETED || this == FAILED; + return this == COMPLETED || this == FAILED || this == NEEDS_ATTENTION; } public InitStage next() { @@ -29,7 +34,7 @@ public InitStage next() { case AI_DESCRIPTION -> RAG_EMBEDDING; case RAG_EMBEDDING -> BRAIN_ANALYSIS; case BRAIN_ANALYSIS -> SEMANTIC_MODELING; - case SEMANTIC_MODELING, COMPLETED, FAILED -> null; + case SEMANTIC_MODELING, COMPLETED, FAILED, NEEDS_ATTENTION -> null; }; } } diff --git a/backend/src/main/java/com/dbaagent/provider/postgres/PostgresIntrospectionProvider.java b/backend/src/main/java/com/dbaagent/provider/postgres/PostgresIntrospectionProvider.java index 592bf8b..63cc158 100644 --- a/backend/src/main/java/com/dbaagent/provider/postgres/PostgresIntrospectionProvider.java +++ b/backend/src/main/java/com/dbaagent/provider/postgres/PostgresIntrospectionProvider.java @@ -20,6 +20,35 @@ public class PostgresIntrospectionProvider implements IntrospectionProvider { private static final String DATABASE_TYPE = "postgres"; private static final String DEFAULT_SCHEMA = "public"; + /** + * Non-system Postgres schemas. Matches Brain classification services and + * docs/oss-ux/E2E_FIX_PROPOSAL.md W2a — never hardcode public alone. + */ + static final String NON_SYSTEM_SCHEMA_SQL = + "NOT IN ('pg_catalog','information_schema','pg_toast') " + + "AND %1$s NOT LIKE 'pg_temp_%%' " + + "AND %1$s NOT LIKE 'pg_toast_temp_%%'"; + + static String nonSystemSchemaPredicate(String column) { + return column + " " + String.format(NON_SYSTEM_SCHEMA_SQL, column); + } + + /** Map / snapshot key that survives duplicate table names across schemas. */ + static String qualifiedTableKey(String schema, String table) { + String s = (schema == null || schema.isBlank()) ? DEFAULT_SCHEMA : schema; + String t = table == null ? "" : table; + return s + "." + t; + } + + /** Display / relationship name: bare for public, schema.table otherwise. */ + static String qualifyForConsumers(String schema, String table) { + String s = (schema == null || schema.isBlank()) ? DEFAULT_SCHEMA : schema; + if (DEFAULT_SCHEMA.equals(s)) { + return table; + } + return s + "." + table; + } + @Value("${db.fetch-size:1000}") private int fetchSize; @@ -43,8 +72,10 @@ public List getDatabaseObjects(Connection connection, String dat private List getTablesAndViews(Connection connection) throws SQLException { List objects = new ArrayList<>(); + String schemaPred = nonSystemSchemaPredicate("t.schemaname"); + String viewPred = nonSystemSchemaPredicate("v.schemaname"); String query = """ - SELECT t.tablename as name, 'table' as type, + SELECT t.schemaname as schema_name, t.tablename as name, 'table' as type, CASE WHEN s.n_live_tup > 0 THEN s.n_live_tup::bigint WHEN s.n_live_tup = 0 AND c.reltuples = 0 THEN 0::bigint @@ -55,25 +86,26 @@ private List getTablesAndViews(Connection connection) throws SQL JOIN pg_namespace n ON n.nspname = t.schemaname JOIN pg_class c ON c.relnamespace = n.oid AND c.relname = t.tablename LEFT JOIN pg_stat_all_tables s ON s.relid = c.oid - WHERE t.schemaname = 'public' AND c.relkind IN ('r', 'p') + WHERE %s AND c.relkind IN ('r', 'p') UNION ALL - SELECT v.viewname as name, 'view' as type, 0 as row_count - FROM pg_views v WHERE v.schemaname = 'public' - ORDER BY type, name - """; + SELECT v.schemaname as schema_name, v.viewname as name, 'view' as type, 0 as row_count + FROM pg_views v WHERE %s + ORDER BY schema_name, type, name + """.formatted(schemaPred, viewPred); try (Statement stmt = connection.createStatement(); ResultSet rs = stmt.executeQuery(query)) { while (rs.next()) { DatabaseObject obj = new DatabaseObject(); + String schemaName = rs.getString("schema_name"); obj.setName(rs.getString("name")); - obj.setSchema(DEFAULT_SCHEMA); + obj.setSchema(schemaName); obj.setType(rs.getString("type")); Long estimatedRowCount = getNullableLong(rs, "row_count"); obj.setRowCount("table".equals(obj.getType()) - ? resolveTableRowCount(connection, DEFAULT_SCHEMA, obj.getName(), estimatedRowCount) + ? resolveTableRowCount(connection, schemaName, obj.getName(), estimatedRowCount) : estimatedRowCount); - obj.setColumns(getTableColumns(connection, DEFAULT_SCHEMA, obj.getName())); + obj.setColumns(getTableColumns(connection, schemaName, obj.getName())); objects.add(obj); } } @@ -84,19 +116,19 @@ private List getFunctions(Connection connection) throws SQLExcep List objects = new ArrayList<>(); String query = """ - SELECT p.proname as name, pg_get_functiondef(p.oid) as definition + SELECT n.nspname as schema_name, p.proname as name, pg_get_functiondef(p.oid) as definition FROM pg_proc p JOIN pg_namespace n ON p.pronamespace = n.oid - WHERE n.nspname = 'public' AND p.prokind = 'f' - ORDER BY p.proname - """; + WHERE %s AND p.prokind = 'f' + ORDER BY n.nspname, p.proname + """.formatted(nonSystemSchemaPredicate("n.nspname")); try (Statement stmt = connection.createStatement(); ResultSet rs = stmt.executeQuery(query)) { while (rs.next()) { DatabaseObject obj = new DatabaseObject(); obj.setName(rs.getString("name")); - obj.setSchema(DEFAULT_SCHEMA); + obj.setSchema(rs.getString("schema_name")); obj.setType("function"); obj.setDefinition(rs.getString("definition")); objects.add(obj); @@ -109,19 +141,19 @@ private List getProcedures(Connection connection) throws SQLExce List objects = new ArrayList<>(); String query = """ - SELECT p.proname as name, pg_get_functiondef(p.oid) as definition + SELECT n.nspname as schema_name, p.proname as name, pg_get_functiondef(p.oid) as definition FROM pg_proc p JOIN pg_namespace n ON p.pronamespace = n.oid - WHERE n.nspname = 'public' AND p.prokind = 'p' - ORDER BY p.proname - """; + WHERE %s AND p.prokind = 'p' + ORDER BY n.nspname, p.proname + """.formatted(nonSystemSchemaPredicate("n.nspname")); try (Statement stmt = connection.createStatement(); ResultSet rs = stmt.executeQuery(query)) { while (rs.next()) { DatabaseObject obj = new DatabaseObject(); obj.setName(rs.getString("name")); - obj.setSchema(DEFAULT_SCHEMA); + obj.setSchema(rs.getString("schema_name")); obj.setType("procedure"); obj.setDefinition(rs.getString("definition")); objects.add(obj); @@ -275,25 +307,25 @@ public SchemaMetadata scanSchema(Connection connection, String database) throws SchemaMetadata schema = new SchemaMetadata(); schema.setDatabaseName(database); - // Get all tables and views - String tablesQuery = "SELECT t.tablename, 'table' as type, " + - "pg_total_relation_size(quote_ident(t.schemaname)||'.'||quote_ident(t.tablename)) as size_bytes, " + - "CASE " + - " WHEN s.n_live_tup > 0 THEN s.n_live_tup::bigint " + - " WHEN s.n_live_tup = 0 AND c.reltuples = 0 THEN 0::bigint " + - " WHEN c.reltuples >= 0 THEN c.reltuples::bigint " + - " ELSE NULL " + - "END as row_count " + - "FROM pg_tables t " + - "JOIN pg_namespace n ON n.nspname = t.schemaname " + - "JOIN pg_class c ON c.relnamespace = n.oid AND c.relname = t.tablename " + - "LEFT JOIN pg_stat_all_tables s ON s.relid = c.oid " + - "WHERE t.schemaname = 'public' AND c.relkind IN ('r', 'p') " + - "UNION ALL " + - "SELECT v.viewname as tablename, 'view' as type, 0 as size_bytes, 0 as row_count " + - "FROM pg_views v " + - "WHERE v.schemaname = 'public' " + - "ORDER BY tablename"; + // Get all tables and views across non-system schemas (W2a). + String tablesQuery = "SELECT t.schemaname, t.tablename, 'table' as type, " + + "pg_total_relation_size(quote_ident(t.schemaname)||'.'||quote_ident(t.tablename)) as size_bytes, " + + "CASE " + + " WHEN s.n_live_tup > 0 THEN s.n_live_tup::bigint " + + " WHEN s.n_live_tup = 0 AND c.reltuples = 0 THEN 0::bigint " + + " WHEN c.reltuples >= 0 THEN c.reltuples::bigint " + + " ELSE NULL " + + "END as row_count " + + "FROM pg_tables t " + + "JOIN pg_namespace n ON n.nspname = t.schemaname " + + "JOIN pg_class c ON c.relnamespace = n.oid AND c.relname = t.tablename " + + "LEFT JOIN pg_stat_all_tables s ON s.relid = c.oid " + + "WHERE " + nonSystemSchemaPredicate("t.schemaname") + " AND c.relkind IN ('r', 'p') " + + "UNION ALL " + + "SELECT v.schemaname, v.viewname as tablename, 'view' as type, 0 as size_bytes, 0 as row_count " + + "FROM pg_views v " + + "WHERE " + nonSystemSchemaPredicate("v.schemaname") + " " + + "ORDER BY schemaname, tablename"; Map tableMap = new HashMap<>(); @@ -302,16 +334,17 @@ public SchemaMetadata scanSchema(Connection connection, String database) throws try (ResultSet rs = stmt.executeQuery(tablesQuery)) { while (rs.next()) { TableMetadata table = new TableMetadata(); + String schemaName = rs.getString("schemaname"); table.setName(rs.getString("tablename")); - table.setSchema(DEFAULT_SCHEMA); + table.setSchema(schemaName); table.setType(rs.getString("type")); table.setSizeBytes(rs.getLong("size_bytes")); Long estimatedRowCount = getNullableLong(rs, "row_count"); table.setRowCount("table".equals(table.getType()) - ? resolveTableRowCount(connection, DEFAULT_SCHEMA, table.getName(), estimatedRowCount) + ? resolveTableRowCount(connection, schemaName, table.getName(), estimatedRowCount) : estimatedRowCount); schema.getTables().add(table); - tableMap.put(table.getName(), table); + tableMap.put(qualifiedTableKey(schemaName, table.getName()), table); } } } @@ -339,32 +372,34 @@ private void applyStatementSettings(Statement stmt) throws SQLException { * Batch load all columns for all tables in a single query (eliminates N+1). */ private void scanPostgreSQLColumnsBatch(Connection connection, Map tableMap) throws SQLException { - // Get all columns with primary key info in a single query - String query = "SELECT c.table_name, c.column_name, c.data_type, c.character_maximum_length, " + - "c.is_nullable, c.column_default, c.ordinal_position, " + - "CASE WHEN pk.column_name IS NOT NULL THEN true ELSE false END as is_primary_key " + - "FROM information_schema.columns c " + - "LEFT JOIN ( " + - " SELECT tc.table_name, ku.column_name " + - " FROM information_schema.table_constraints tc " + - " JOIN information_schema.key_column_usage ku " + - " ON tc.constraint_name = ku.constraint_name AND tc.table_name = ku.table_name " + - " WHERE tc.table_schema = 'public' AND tc.constraint_type = 'PRIMARY KEY' " + - ") pk ON c.table_name = pk.table_name AND c.column_name = pk.column_name " + - "WHERE c.table_schema = 'public' " + - "ORDER BY c.table_name, c.ordinal_position"; + String schemaPred = nonSystemSchemaPredicate("c.table_schema"); + String pkPred = nonSystemSchemaPredicate("tc.table_schema"); + String query = "SELECT c.table_schema, c.table_name, c.column_name, c.data_type, c.character_maximum_length, " + + "c.is_nullable, c.column_default, c.ordinal_position, " + + "CASE WHEN pk.column_name IS NOT NULL THEN true ELSE false END as is_primary_key " + + "FROM information_schema.columns c " + + "LEFT JOIN ( " + + " SELECT tc.table_schema, tc.table_name, ku.column_name " + + " FROM information_schema.table_constraints tc " + + " JOIN information_schema.key_column_usage ku " + + " ON tc.constraint_name = ku.constraint_name AND tc.table_schema = ku.table_schema " + + " AND tc.table_name = ku.table_name " + + " WHERE " + pkPred + " AND tc.constraint_type = 'PRIMARY KEY' " + + ") pk ON c.table_schema = pk.table_schema AND c.table_name = pk.table_name " + + " AND c.column_name = pk.column_name " + + "WHERE " + schemaPred + " " + + "ORDER BY c.table_schema, c.table_name, c.ordinal_position"; try (Statement stmt = connection.createStatement()) { applyStatementSettings(stmt); try (ResultSet rs = stmt.executeQuery(query)) { while (rs.next()) { - String tableName = rs.getString("table_name"); - TableMetadata table = tableMap.get(tableName); + String key = qualifiedTableKey(rs.getString("table_schema"), rs.getString("table_name")); + TableMetadata table = tableMap.get(key); if (table != null) { ColumnMetadata column = new ColumnMetadata(); column.setName(rs.getString("column_name")); column.setDataType(rs.getString("data_type")); - // Use getLong with null check for character_maximum_length (PostgreSQL returns int4) Object maxLen = rs.getObject("character_maximum_length"); column.setMaxLength(maxLen != null ? ((Number) maxLen).longValue() : null); column.setNullable("YES".equals(rs.getString("is_nullable"))); @@ -382,27 +417,31 @@ private void scanPostgreSQLColumnsBatch(Connection connection, Map tableMap) throws SQLException { - String query = "SELECT i.tablename, i.indexname, i.indexdef, " + - "array_agg(a.attname ORDER BY array_position(ix.indkey, a.attnum)) as columns, " + - "ix.indisunique " + - "FROM pg_indexes i " + - "JOIN pg_class c ON c.relname = i.tablename AND c.relnamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'public') " + - "JOIN pg_index ix ON ix.indexrelid = (SELECT oid FROM pg_class WHERE relname = i.indexname AND relnamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'public')) " + - "JOIN pg_attribute a ON a.attrelid = c.oid AND a.attnum = ANY(ix.indkey) " + - "WHERE i.schemaname = 'public' " + - "GROUP BY i.tablename, i.indexname, i.indexdef, ix.indisunique " + - "ORDER BY i.tablename, i.indexname"; + String schemaPred = nonSystemSchemaPredicate("i.schemaname"); + String query = "SELECT i.schemaname, i.tablename, i.indexname, i.indexdef, " + + "array_agg(a.attname ORDER BY array_position(ix.indkey, a.attnum)) as columns, " + + "ix.indisunique " + + "FROM pg_indexes i " + + "JOIN pg_namespace n ON n.nspname = i.schemaname " + + "JOIN pg_class c ON c.relname = i.tablename AND c.relnamespace = n.oid " + + "JOIN pg_class ic ON ic.relname = i.indexname AND ic.relnamespace = n.oid " + + "JOIN pg_index ix ON ix.indexrelid = ic.oid " + + "JOIN pg_attribute a ON a.attrelid = c.oid AND a.attnum = ANY(ix.indkey) " + + "WHERE " + schemaPred + " " + + "GROUP BY i.schemaname, i.tablename, i.indexname, i.indexdef, ix.indisunique " + + "ORDER BY i.schemaname, i.tablename, i.indexname"; try (Statement stmt = connection.createStatement()) { applyStatementSettings(stmt); try (ResultSet rs = stmt.executeQuery(query)) { while (rs.next()) { + String schemaName = rs.getString("schemaname"); String tableName = rs.getString("tablename"); - TableMetadata table = tableMap.get(tableName); + TableMetadata table = tableMap.get(qualifiedTableKey(schemaName, tableName)); if (table != null) { IndexMetadata index = new IndexMetadata(); index.setName(rs.getString("indexname")); - index.setTableName(tableName); + index.setTableName(qualifyForConsumers(schemaName, tableName)); index.setUnique(rs.getBoolean("indisunique")); Array columnArray = rs.getArray("columns"); @@ -427,26 +466,28 @@ private void scanPostgreSQLIndexesBatch(Connection connection, Map getForeignKeys(Connection connection, String d String query = """ SELECT tc.constraint_name, + tc.table_schema as source_schema, tc.table_name as source_table, kcu.column_name as source_column, + ccu.table_schema as target_schema, ccu.table_name as target_table, ccu.column_name as target_column FROM information_schema.table_constraints tc JOIN information_schema.key_column_usage kcu - ON tc.constraint_name = kcu.constraint_name + ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema JOIN information_schema.constraint_column_usage ccu - ON tc.constraint_name = ccu.constraint_name + ON tc.constraint_name = ccu.constraint_name AND tc.constraint_schema = ccu.constraint_schema WHERE tc.constraint_type = 'FOREIGN KEY' - AND tc.table_schema = 'public' - ORDER BY tc.table_name, tc.constraint_name - """; + AND %s + ORDER BY tc.table_schema, tc.table_name, tc.constraint_name + """.formatted(nonSystemSchemaPredicate("tc.table_schema")); try (Statement stmt = connection.createStatement(); ResultSet rs = stmt.executeQuery(query)) { while (rs.next()) { RelationshipMetadata rel = new RelationshipMetadata(); rel.setConstraintName(rs.getString("constraint_name")); - rel.setFromTable(rs.getString("source_table")); + rel.setFromTable(qualifyForConsumers(rs.getString("source_schema"), rs.getString("source_table"))); rel.setFromColumn(rs.getString("source_column")); - rel.setToTable(rs.getString("target_table")); + rel.setToTable(qualifyForConsumers(rs.getString("target_schema"), rs.getString("target_table"))); rel.setToColumn(rs.getString("target_column")); relationships.add(rel); } @@ -495,6 +538,13 @@ public List getForeignKeys(Connection connection, String d @Override public List getColumnDetails(Connection connection, String database, String tableName) throws SQLException { List columns = new ArrayList<>(); + String schemaName = DEFAULT_SCHEMA; + String bareTable = tableName; + if (tableName != null && tableName.contains(".")) { + int dot = tableName.indexOf('.'); + schemaName = tableName.substring(0, dot); + bareTable = tableName.substring(dot + 1); + } String query = """ SELECT @@ -502,12 +552,13 @@ public List getColumnDetails(Connection connection, String databas data_type, character_maximum_length, numeric_precision, numeric_scale, udt_name FROM information_schema.columns - WHERE table_schema = 'public' AND table_name = ? + WHERE table_schema = ? AND table_name = ? ORDER BY ordinal_position """; try (PreparedStatement stmt = connection.prepareStatement(query)) { - stmt.setString(1, tableName); + stmt.setString(1, schemaName); + stmt.setString(2, bareTable); try (ResultSet rs = stmt.executeQuery()) { while (rs.next()) { ColumnDetail col = ColumnDetail.builder() @@ -547,12 +598,21 @@ public List getConstraintDetails(Connection connection, String LEFT JOIN information_schema.constraint_column_usage ccu ON tc.constraint_name = ccu.constraint_name AND tc.constraint_type = 'FOREIGN KEY' - WHERE tc.table_schema = 'public' AND tc.table_name = ? + WHERE tc.table_schema = ? AND tc.table_name = ? ORDER BY tc.constraint_name """; + String schemaName = DEFAULT_SCHEMA; + String bareTable = tableName; + if (tableName != null && tableName.contains(".")) { + int dot = tableName.indexOf('.'); + schemaName = tableName.substring(0, dot); + bareTable = tableName.substring(dot + 1); + } + try (PreparedStatement stmt = connection.prepareStatement(query)) { - stmt.setString(1, tableName); + stmt.setString(1, schemaName); + stmt.setString(2, bareTable); try (ResultSet rs = stmt.executeQuery()) { while (rs.next()) { String constraintName = rs.getString("constraint_name"); @@ -802,9 +862,9 @@ public List> getAllTablesWithMetadata(Connection connection, JOIN pg_namespace n ON n.nspname = t.schemaname JOIN pg_class c ON c.relnamespace = n.oid AND c.relname = t.tablename LEFT JOIN pg_stat_all_tables s ON s.relid = c.oid - WHERE t.schemaname = 'public' AND c.relkind IN ('r', 'p') - ORDER BY t.tablename - """; + WHERE %s AND c.relkind IN ('r', 'p') + ORDER BY t.schemaname, t.tablename + """.formatted(nonSystemSchemaPredicate("t.schemaname")); try (Statement stmt = connection.createStatement(); ResultSet rs = stmt.executeQuery(query)) { diff --git a/backend/src/main/java/com/dbaagent/service/AgentBridgeService.java b/backend/src/main/java/com/dbaagent/service/AgentBridgeService.java index 7907447..5c75cbd 100644 --- a/backend/src/main/java/com/dbaagent/service/AgentBridgeService.java +++ b/backend/src/main/java/com/dbaagent/service/AgentBridgeService.java @@ -26,8 +26,10 @@ * *

The Java backend can't run the agent shell tooling itself (no agent runtime * in its image), so provisioning is delegated over the compose network to the - * agent's internal, secret-gated provisioner endpoint. Failures never block the - * tab — we still return the profile name. + * agent's internal, secret-gated provisioner endpoint. The browser Agent-tab + * boot path ({@link #ensureProfile}) is fail-loud — a configured-but-failing + * provisioner throws rather than returning a profile with a stale disk token. + * Headless channels ({@link #ensureProfileForUser}) stay best-effort. */ @Service public class AgentBridgeService { @@ -91,24 +93,60 @@ public AgentBridgeService(McpTokenService mcpTokenService) { @Value("${security.session.refresh-days:7}") private long sessionWindowDays; + /** + * Base URL for this same backend's own REST API, used only by + * {@link #probeMcpAuth} to verify a freshly minted token actually works + * before the Agent tab opens chat. Loopback by default — the probe never + * needs to leave the box, so no external base-URL config is required. + */ + @Value("${agent.local-api-base-url:http://127.0.0.1:${server.port:8080}/api}") + private String localApiBaseUrl; + public String profileFor(String username) { String safe = username.toLowerCase().replaceAll("[^a-z0-9]+", "-").replaceAll("(^-+|-+$)", ""); return "u-" + safe; } + /** + * Thrown when provisioning is configured (enabled + secret set) but the + * provisioner call itself fails — non-2xx response or a connect/timeout + * error. Callers must surface this rather than silently returning a + * profile name that may point at a stale disk token (the W1 fix for + * "Agent tab opens, tool calls 401 six steps later"). + */ + public static class ProvisioningException extends RuntimeException { + public ProvisioningException(String message) { + super(message); + } + public ProvisioningException(String message, Throwable cause) { + super(message, cause); + } + } + + /** Result of {@link #ensureProfile}: the resolved profile plus the token actually provisioned with it. */ + public record ProfileBootstrap(String profile, String token) {} + /** * Ensure the user's agent profile exists and is bound to their current token; - * returns the profile name. Best-effort — provisioning problems are logged but - * never thrown (the Agent tab still opens, just without fresh per-user scope). + * returns the profile name plus the token that was provisioned with it (so + * the caller can probe that exact credential — see + * {@link AgentBridgeService#probeMcpAuth}). + * + *

Fail-loud: when provisioning is configured (enabled + secret set), a + * non-2xx or unreachable provisioner throws {@link ProvisioningException} + * instead of returning a profile that may still carry a stale/expired disk + * token. When provisioning is disabled or unconfigured, the tab still opens + * against the shared default profile (documented, not silent) and the + * returned token is the caller-supplied session token, unprovisioned. */ - public String ensureProfile(String username, String authToken, String connectionId) { + public ProfileBootstrap ensureProfile(String username, String authToken, String connectionId) { String profile = profileFor(username); if (!provisionEnabled) { - return profile; + return new ProfileBootstrap(profile, authToken); } if (provisionSecret == null || provisionSecret.isBlank()) { log.warn("agent.provision-secret is unset — skipping per-user provisioning for {}", username); - return profile; + return new ProfileBootstrap(profile, authToken); } // The agent profile must carry a credential that outlives a single chat // session. The user's session JWT lives only ~15 min and is coupled to a @@ -122,16 +160,21 @@ public String ensureProfile(String username, String authToken, String connection agentToken = authToken == null ? "" : authToken; } callProvisioner(username, profile, agentToken, connectionId); - return profile; + return new ProfileBootstrap(profile, agentToken); } /** - * Headless variant for non-browser channels (Slack, etc.): provision the - * user's profile with a dedicated CHANNEL token minted directly for the - * DeepSQL user — no inbound session/cookie needed. The channel token has its - * own name so the UI login/logout lifecycle (which extends/revokes the - * {@code (auto)} token) never touches it; a web logout won't kill the user's - * Slack agent access. Best-effort — never throws. + * Headless variant for non-browser channels (Slack, dashboard generation, + * the agent-chat turn API): provision the user's profile with a dedicated + * CHANNEL token minted directly for the DeepSQL user — no inbound + * session/cookie needed. The channel token has its own name so the UI + * login/logout lifecycle (which extends/revokes the {@code (auto)} token) + * never touches it; a web logout won't kill the user's Slack agent access. + * + *

Unlike {@link #ensureProfile} (the browser Agent-tab boot path, which + * is fail-loud), this stays best-effort: a provisioner hiccup here must not + * take down dashboard generation or a Slack turn over a transient network + * blip. Provisioning failures are logged, not propagated. */ public String ensureProfileForUser(String username, String connectionId) { String profile = profileFor(username); @@ -147,11 +190,24 @@ public String ensureProfileForUser(String username, String connectionId) { log.warn("Could not mint channel token for {} — skipping headless provisioning", username); return profile; } - callProvisioner(username, profile, channelToken, connectionId); + try { + callProvisioner(username, profile, channelToken, connectionId); + } catch (ProvisioningException e) { + log.warn("Headless agent provisioning failed for {}: {}", username, e.getMessage()); + } return profile; } - /** POST the provision request to the agent container's internal provisioner. */ + /** + * POST the provision request to the agent container's internal provisioner. + * + *

Throws {@link ProvisioningException} on a non-2xx response or any + * connect/timeout/IO failure — the caller (both {@code ensureProfile} + * variants) has already confirmed provisioning is enabled and configured, so + * a failure here means the Agent tab is about to open against a profile the + * provisioner never actually refreshed. That must block the tab, not log a + * warning and proceed. + */ private void callProvisioner(String username, String profile, String token, String connectionId) { try { String body = objectMapper.writeValueAsString(Map.of( @@ -168,10 +224,91 @@ private void callProvisioner(String username, String profile, String token, Stri if (resp.statusCode() / 100 == 2) { log.info("Provisioned/refreshed agent profile {} for user {}", profile, username); } else { - log.warn("Agent provisioning HTTP {} for user {}: {}", resp.statusCode(), username, resp.body()); + String message = "Agent provisioning HTTP " + resp.statusCode() + " for user " + username + + ": " + resp.body(); + log.warn(message); + throw new ProvisioningException(message); } + } catch (ProvisioningException e) { + throw e; } catch (Exception e) { log.warn("Agent provisioning call failed for user {}: {}", username, e.getMessage()); + throw new ProvisioningException( + "Agent provisioning call failed for user " + username + ": " + e.getMessage(), e); + } + } + + /** + * Derive the provisioner's revoke endpoint from its configured provision + * URL ({@code .../provision} -> {@code .../revoke}). Returns null if the + * configured URL doesn't follow that convention (best-effort only). + */ + private String revokeUrl() { + if (provisionerUrl == null || !provisionerUrl.contains("/provision")) { + return null; + } + return provisionerUrl.replace("/provision", "/revoke"); + } + + /** + * Best-effort POST to the provisioner's {@code /revoke} so the on-disk + * token file / env fallback are cleared alongside the DB-side revoke. + * Never throws — this runs after the DB token is already gone, so a + * provisioner hiccup here must not fail the logout request itself. + */ + private void callProvisionerRevoke(String username) { + if (!provisionEnabled) { + return; + } + String url = revokeUrl(); + if (url == null || provisionSecret == null || provisionSecret.isBlank()) { + return; + } + try { + String body = objectMapper.writeValueAsString(Map.of("user", username)); + HttpRequest req = HttpRequest.newBuilder(URI.create(url)) + .header("Content-Type", "application/json") + .header("X-Provision-Secret", provisionSecret) + .timeout(Duration.ofSeconds(10)) + .POST(HttpRequest.BodyPublishers.ofString(body)) + .build(); + HttpResponse resp = http.send(req, HttpResponse.BodyHandlers.ofString()); + if (resp.statusCode() / 100 == 2) { + log.info("Revoked on-disk agent token for user {}", username); + } else { + log.warn("Agent token revoke HTTP {} for user {}: {}", resp.statusCode(), username, resp.body()); + } + } catch (Exception e) { + log.warn("Agent token revoke call failed for user {}: {}", username, e.getMessage()); + } + } + + /** + * Probe whether the just-minted/refreshed MCP token can actually reach the + * DeepSQL API — the health check the Agent tab boot depends on to decide + * whether to show the chat composer or a blocking "Agent cannot reach + * DeepSQL (auth)" banner. GETs {@code /connections} with the token as a + * bearer credential against the local backend (loopback — this call never + * leaves the box, so no external base-URL config is needed). + * + * @return true if the API accepted the token (2xx), false on any + * non-2xx/auth failure or network error. + */ + public boolean probeMcpAuth(String token) { + if (token == null || token.isBlank()) { + return false; + } + try { + HttpRequest req = HttpRequest.newBuilder(URI.create(localApiBaseUrl + "/connections")) + .header("Authorization", "Bearer " + token) + .timeout(Duration.ofSeconds(5)) + .GET() + .build(); + HttpResponse resp = http.send(req, HttpResponse.BodyHandlers.ofString()); + return resp.statusCode() / 100 == 2; + } catch (Exception e) { + log.warn("MCP auth probe failed: {}", e.getMessage()); + return false; } } @@ -258,5 +395,10 @@ public void revokeAgentTokens(String username) { } catch (Exception e) { log.warn("Could not revoke agent token(s) for {}: {}", username, e.getMessage()); } + // DB-side revoke only kills future auth checks — the plaintext token can + // still live on disk in the profile's .env / token file / MCP server env + // until the provisioner overwrites it. Clear that copy too so a revoked + // token can't keep the agent working. Best-effort: never blocks logout. + callProvisionerRevoke(username); } } diff --git a/backend/src/main/java/com/dbaagent/service/CredentialService.java b/backend/src/main/java/com/dbaagent/service/CredentialService.java index fe5755c..42b5f37 100644 --- a/backend/src/main/java/com/dbaagent/service/CredentialService.java +++ b/backend/src/main/java/com/dbaagent/service/CredentialService.java @@ -2,6 +2,7 @@ import com.dbaagent.model.ConnectionRequest; import com.dbaagent.model.DatabaseConnection; +import com.dbaagent.provider.DatabaseProviderRegistry; import com.dbaagent.repository.CredentialRepository; import com.dbaagent.security.EncryptionService; import com.dbaagent.service.security.ConnectionAccessService; @@ -26,13 +27,18 @@ public class CredentialService { private final EncryptionService encryptionService; private final ConnectionAccessService connectionAccessService; private final TelemetryClient telemetryClient; + private final DatabaseProviderRegistry providerRegistry; @Transactional public DatabaseConnection saveConnection(ConnectionRequest request, String ownerUsername) { DatabaseConnection connection = new DatabaseConnection(); connection.setId(UUID.randomUUID().toString()); connection.setConnectionName(request.getConnectionName()); - connection.setDbType(request.getDbType()); + // Canonicalize through the provider registry ("postgresql" -> "postgres", etc.) so + // every downstream consumer that switches on dbType (DatabaseProviderRegistry.getDialect, + // frontend badges, telemetry) sees one spelling per dialect regardless of which alias + // the caller (onboarding wizard, API client, import) happened to send. + connection.setDbType(providerRegistry.getCanonicalName(request.getDbType())); connection.setOwnerUsername(ownerUsername); connection.setCreatedAt(LocalDateTime.now()); connection.setLastUsed(LocalDateTime.now()); @@ -377,7 +383,7 @@ public DatabaseConnection updateConnection(String connectionId, ConnectionReques // Update non-encrypted fields connection.setConnectionName(request.getConnectionName()); - connection.setDbType(request.getDbType()); + connection.setDbType(providerRegistry.getCanonicalName(request.getDbType())); connection.setLastUsed(LocalDateTime.now()); // Update and re-encrypt sensitive fields diff --git a/backend/src/main/java/com/dbaagent/service/QueryExecutorService.java b/backend/src/main/java/com/dbaagent/service/QueryExecutorService.java index 3f29804..0c14cc9 100644 --- a/backend/src/main/java/com/dbaagent/service/QueryExecutorService.java +++ b/backend/src/main/java/com/dbaagent/service/QueryExecutorService.java @@ -394,42 +394,48 @@ private List getMySQLColumns(Connection connection, String database, private List getPostgreSQLObjects(Connection connection) throws SQLException { List objects = new ArrayList<>(); + // Keep in sync with PostgresIntrospectionProvider non-system schema filter (W2a). + String nonSystem = "NOT IN ('pg_catalog','information_schema','pg_toast') " + + "AND %1$s NOT LIKE 'pg_temp_%%' AND %1$s NOT LIKE 'pg_toast_temp_%%'"; // Get tables and views - String tablesQuery = "SELECT t.tablename as name, 'table' as type, " + - "(SELECT reltuples::bigint FROM pg_class WHERE relname = t.tablename) as row_count " + - "FROM pg_tables t WHERE t.schemaname = 'public' " + - "UNION ALL " + - "SELECT v.viewname as name, 'view' as type, 0 as row_count " + - "FROM pg_views v WHERE v.schemaname = 'public' " + - "ORDER BY type, name"; + String tablesQuery = "SELECT t.schemaname as schema_name, t.tablename as name, 'table' as type, " + + "(SELECT reltuples::bigint FROM pg_class c " + + " JOIN pg_namespace n ON c.relnamespace = n.oid " + + " WHERE n.nspname = t.schemaname AND c.relname = t.tablename) as row_count " + + "FROM pg_tables t WHERE t.schemaname " + String.format(nonSystem, "t.schemaname") + " " + + "UNION ALL " + + "SELECT v.schemaname as schema_name, v.viewname as name, 'view' as type, 0 as row_count " + + "FROM pg_views v WHERE v.schemaname " + String.format(nonSystem, "v.schemaname") + " " + + "ORDER BY schema_name, type, name"; try (Statement stmt = connection.createStatement(); ResultSet rs = stmt.executeQuery(tablesQuery)) { while (rs.next()) { DatabaseObject obj = new DatabaseObject(); + String schemaName = rs.getString("schema_name"); obj.setName(rs.getString("name")); - obj.setSchema("public"); + obj.setSchema(schemaName); obj.setType(rs.getString("type")); obj.setRowCount(rs.getLong("row_count")); - obj.setColumns(getPostgreSQLColumns(connection, obj.getName())); + obj.setColumns(getPostgreSQLColumns(connection, schemaName, obj.getName())); objects.add(obj); } } // Get functions - String functionsQuery = "SELECT p.proname as name, pg_get_functiondef(p.oid) as definition " + - "FROM pg_proc p " + - "JOIN pg_namespace n ON p.pronamespace = n.oid " + - "WHERE n.nspname = 'public' AND p.prokind = 'f' " + - "ORDER BY p.proname"; + String functionsQuery = "SELECT n.nspname as schema_name, p.proname as name, pg_get_functiondef(p.oid) as definition " + + "FROM pg_proc p " + + "JOIN pg_namespace n ON p.pronamespace = n.oid " + + "WHERE n.nspname " + String.format(nonSystem, "n.nspname") + " AND p.prokind = 'f' " + + "ORDER BY n.nspname, p.proname"; try (Statement stmt = connection.createStatement(); ResultSet rs = stmt.executeQuery(functionsQuery)) { while (rs.next()) { DatabaseObject obj = new DatabaseObject(); obj.setName(rs.getString("name")); - obj.setSchema("public"); + obj.setSchema(rs.getString("schema_name")); obj.setType("function"); obj.setDefinition(rs.getString("definition")); objects.add(obj); @@ -437,18 +443,18 @@ private List getPostgreSQLObjects(Connection connection) throws } // Get procedures - String proceduresQuery = "SELECT p.proname as name, pg_get_functiondef(p.oid) as definition " + - "FROM pg_proc p " + - "JOIN pg_namespace n ON p.pronamespace = n.oid " + - "WHERE n.nspname = 'public' AND p.prokind = 'p' " + - "ORDER BY p.proname"; + String proceduresQuery = "SELECT n.nspname as schema_name, p.proname as name, pg_get_functiondef(p.oid) as definition " + + "FROM pg_proc p " + + "JOIN pg_namespace n ON p.pronamespace = n.oid " + + "WHERE n.nspname " + String.format(nonSystem, "n.nspname") + " AND p.prokind = 'p' " + + "ORDER BY n.nspname, p.proname"; try (Statement stmt = connection.createStatement(); ResultSet rs = stmt.executeQuery(proceduresQuery)) { while (rs.next()) { DatabaseObject obj = new DatabaseObject(); obj.setName(rs.getString("name")); - obj.setSchema("public"); + obj.setSchema(rs.getString("schema_name")); obj.setType("procedure"); obj.setDefinition(rs.getString("definition")); objects.add(obj); @@ -458,23 +464,26 @@ private List getPostgreSQLObjects(Connection connection) throws return objects; } - private List getPostgreSQLColumns(Connection connection, String tableName) throws SQLException { + private List getPostgreSQLColumns(Connection connection, String schemaName, String tableName) throws SQLException { List columns = new ArrayList<>(); - String query = "SELECT c.column_name, c.data_type, c.is_nullable, c.column_default, " + - "CASE WHEN pk.column_name IS NOT NULL THEN true ELSE false END as is_primary_key " + - "FROM information_schema.columns c " + - "LEFT JOIN ( " + - " SELECT ku.column_name " + - " FROM information_schema.table_constraints tc " + - " JOIN information_schema.key_column_usage ku ON tc.constraint_name = ku.constraint_name " + - " WHERE tc.constraint_type = 'PRIMARY KEY' AND ku.table_name = ? " + - ") pk ON c.column_name = pk.column_name " + - "WHERE c.table_name = ? AND c.table_schema = 'public' " + - "ORDER BY c.ordinal_position"; + String query = "SELECT c.column_name, c.data_type, c.is_nullable, c.column_default, " + + "CASE WHEN pk.column_name IS NOT NULL THEN true ELSE false END as is_primary_key " + + "FROM information_schema.columns c " + + "LEFT JOIN ( " + + " SELECT ku.column_name " + + " FROM information_schema.table_constraints tc " + + " JOIN information_schema.key_column_usage ku ON tc.constraint_name = ku.constraint_name " + + " AND tc.table_schema = ku.table_schema " + + " WHERE tc.constraint_type = 'PRIMARY KEY' AND ku.table_schema = ? AND ku.table_name = ? " + + ") pk ON c.column_name = pk.column_name " + + "WHERE c.table_schema = ? AND c.table_name = ? " + + "ORDER BY c.ordinal_position"; try (PreparedStatement stmt = connection.prepareStatement(query)) { - stmt.setString(1, tableName); + stmt.setString(1, schemaName); stmt.setString(2, tableName); + stmt.setString(3, schemaName); + stmt.setString(4, tableName); try (ResultSet rs = stmt.executeQuery()) { while (rs.next()) { ColumnInfo col = new ColumnInfo(); diff --git a/backend/src/main/java/com/dbaagent/service/SchemaIntrospectionService.java b/backend/src/main/java/com/dbaagent/service/SchemaIntrospectionService.java index 4dd9b37..4a63162 100644 --- a/backend/src/main/java/com/dbaagent/service/SchemaIntrospectionService.java +++ b/backend/src/main/java/com/dbaagent/service/SchemaIntrospectionService.java @@ -41,9 +41,17 @@ public TableDetails getTableDetails(String connectionId, String tableName) { Long rowCount = provider.getTableRowCount(connection, database, tableName); String tableSize = provider.getTableSize(connection, database, tableName); + String schemaName = provider.getDefaultSchema(); + String bareName = tableName; + if (tableName != null && tableName.contains(".")) { + int dot = tableName.indexOf('.'); + schemaName = tableName.substring(0, dot); + bareName = tableName.substring(dot + 1); + } + return TableDetails.builder() - .tableName(tableName) - .tableSchema(provider.getDefaultSchema()) + .tableName(bareName != null ? bareName : tableName) + .tableSchema(schemaName) .columns(columns) .indexes(indexes) .constraints(constraints) diff --git a/backend/src/main/java/com/dbaagent/service/brain/keycolumn/KeyColumnAnalysisService.java b/backend/src/main/java/com/dbaagent/service/brain/keycolumn/KeyColumnAnalysisService.java index b983aee..36519f7 100644 --- a/backend/src/main/java/com/dbaagent/service/brain/keycolumn/KeyColumnAnalysisService.java +++ b/backend/src/main/java/com/dbaagent/service/brain/keycolumn/KeyColumnAnalysisService.java @@ -1107,8 +1107,16 @@ private List detectAntiPatterns(String connectionId, LocalDateTime now = LocalDateTime.now(); for (KeyColumnAnalysis analysis : analyses) { + // A column that is already indexed (indexName set by enrichWithIndexStats) + // or is a known key (TRUE_KEY, or a PRIMARY/UNIQUE label from any future + // classifier) can never be flagged UNINDEXED_* — that combination is a + // contradiction, not a finding. This is what stops resolved primary keys + // from generating "unindexed join/filter" noise every single run. + boolean isKnownKeyOrIndexed = analysis.getIndexName() != null + || isKeyLikeType(analysis.getKeyType()); + // Rule 1: Unindexed filter columns (skip if already indexed) - if (analysis.getWhereCount() >= 5 && analysis.getIndexName() == null) { + if (analysis.getWhereCount() >= 5 && !isKnownKeyOrIndexed) { ColumnAntiPattern pattern = ColumnAntiPattern.builder() .connectionId(connectionId) .tableName(analysis.getTableName()) @@ -1133,7 +1141,7 @@ private List detectAntiPatterns(String connectionId, } // Rule 2: Unindexed JOIN columns (skip if already indexed) - if (analysis.getJoinCount() >= 5 && analysis.getIndexName() == null) { + if (analysis.getJoinCount() >= 5 && !isKnownKeyOrIndexed) { ColumnAntiPattern pattern = ColumnAntiPattern.builder() .connectionId(connectionId) .tableName(analysis.getTableName()) @@ -1158,7 +1166,7 @@ private List detectAntiPatterns(String connectionId, } // Rule 3: Unindexed ORDER BY (skip if already indexed) - if (analysis.getOrderByCount() >= 3 && analysis.getIndexName() == null) { + if (analysis.getOrderByCount() >= 3 && !isKnownKeyOrIndexed) { ColumnAntiPattern pattern = ColumnAntiPattern.builder() .connectionId(connectionId) .tableName(analysis.getTableName()) @@ -1273,6 +1281,16 @@ private List detectAntiPatterns(String connectionId, return patterns; } + /** + * True for any keyType label that means "this column is already a real key" — + * TRUE_KEY is what this classifier actually assigns (see classifyKeys), while + * PRIMARY/UNIQUE are accepted defensively in case a future classifier or an + * imported/legacy row uses those labels instead. + */ + private boolean isKeyLikeType(String keyType) { + return "TRUE_KEY".equals(keyType) || "PRIMARY".equals(keyType) || "UNIQUE".equals(keyType); + } + /** * Safely get skew category with error handling */ @@ -1939,34 +1957,76 @@ private void calculateEnhancedScore(List analyses) { } /** - * Fetch index usage statistics from database metadata + * Fetch index metadata via {@link QueryExecutorService#getTableIndexes} (dialect-agnostic — + * dispatches through {@code IntrospectionProvider}, not a Postgres-specific query) and set + * {@code indexName} on every analysis whose column is covered by an index. This is what + * {@link #detectAntiPatterns} gates UNINDEXED_* on — before this method actually populated + * indexName, every column (including primary keys) looked unindexed forever, so PK/UK + * columns kept generating UNINDEXED_JOIN/UNINDEXED_FILTER noise no matter how they were + * actually indexed. */ private void enrichWithIndexStats(List analyses, String connectionId) { - log.info("Fetching index usage statistics"); + log.info("Fetching index metadata for {} key column analyses", analyses.size()); + Map> indexesByTable = new HashMap<>(); for (KeyColumnAnalysis analysis : analyses) { - try { - // Query database for index info - PostgreSQL specific - String sql = String.format( - "SELECT indexname, idx_scan FROM pg_stat_user_indexes " + - "WHERE schemaname = 'public' AND tablename = '%s' " + - "AND indexdef LIKE '%%%s%%'", - analysis.getTableName(), analysis.getColumnName() - ); + String tableName = analysis.getTableName(); + if (tableName == null) { + continue; + } + List indexes = indexesByTable.computeIfAbsent(tableName, t -> { + try { + return queryExecutorService.getTableIndexes(connectionId, t); + } catch (Exception e) { + log.debug("Could not fetch indexes for table {}: {}", t, e.getMessage()); + return Collections.emptyList(); + } + }); + if (indexes.isEmpty()) { + continue; + } - // Execute query using QueryExecutorService - // Note: This would need proper implementation based on database type - // For now, just mark as analyzed - analysis.setIndexUsageCount(0L); - analysis.setIndexScanCount(0L); + String columnName = analysis.getColumnName(); + // Prefer the primary-key index, then any unique index, then any index + // that covers the column — matches how detectAntiPatterns treats + // PRIMARY/UNIQUE as strictly stronger signal than a plain index. + TableIndex best = null; + for (TableIndex index : indexes) { + if (index.getColumns() == null || !containsColumnIgnoreCase(index.getColumns(), columnName)) { + continue; + } + if (index.isPrimary()) { + best = index; + break; + } + if (best == null || (index.isUnique() && !best.isUnique())) { + best = index; + } + } - } catch (Exception e) { - log.debug("Could not fetch index stats for {}.{}: {}", - analysis.getTableName(), analysis.getColumnName(), e.getMessage()); + if (best != null) { + analysis.setIndexName(best.getName()); + if ("NON_KEY".equals(analysis.getKeyType()) || analysis.getKeyType() == null) { + if (best.isPrimary()) { + analysis.setKeyType("TRUE_KEY"); + } + } } } } + private boolean containsColumnIgnoreCase(List columns, String columnName) { + if (columnName == null) { + return false; + } + for (String column : columns) { + if (columnName.equalsIgnoreCase(column)) { + return true; + } + } + return false; + } + /** * Detect and recommend composite indexes */ diff --git a/backend/src/main/java/com/dbaagent/service/scheduler/BrainInitSchedulerService.java b/backend/src/main/java/com/dbaagent/service/scheduler/BrainInitSchedulerService.java index 52d441f..1742f91 100644 --- a/backend/src/main/java/com/dbaagent/service/scheduler/BrainInitSchedulerService.java +++ b/backend/src/main/java/com/dbaagent/service/scheduler/BrainInitSchedulerService.java @@ -549,7 +549,7 @@ private int stageStartPercent(InitStage stage) { case RAG_EMBEDDING -> 80; case BRAIN_ANALYSIS -> 92; case SEMANTIC_MODELING -> 96; - case COMPLETED, FAILED -> 100; + case COMPLETED, FAILED, NEEDS_ATTENTION -> 100; }; } diff --git a/backend/src/main/java/com/dbaagent/service/scheduler/BrainInitStageExecutor.java b/backend/src/main/java/com/dbaagent/service/scheduler/BrainInitStageExecutor.java index 3c8b2df..dbdc8a5 100644 --- a/backend/src/main/java/com/dbaagent/service/scheduler/BrainInitStageExecutor.java +++ b/backend/src/main/java/com/dbaagent/service/scheduler/BrainInitStageExecutor.java @@ -120,7 +120,7 @@ public InitStage executeStage(BrainInitTaskData data) { case RAG_EMBEDDING -> executeRagEmbedding(connectionId, status, taskRunId); case BRAIN_ANALYSIS -> executeBrainAnalysis(connectionId, status, taskRunId); case SEMANTIC_MODELING -> executeSemanticModeling(connectionId, status, taskRunId); - case COMPLETED, FAILED -> null; + case COMPLETED, FAILED, NEEDS_ATTENTION -> null; }; } catch (Exception e) { if (isStaleOrCancelled(connectionId, taskRunId)) { @@ -143,28 +143,96 @@ private InitStage executeSchemaScan(String connectionId, ConnectionInitStatus st var schema = schemaScannerService.scanSchema(connectionId); int tablesDiscovered = schema.getTables() != null ? schema.getTables().size() : 0; + int baseTablesDiscovered = schema.getTables() != null + ? (int) schema.getTables().stream() + .filter(t -> t.getType() == null || "table".equalsIgnoreCase(t.getType())) + .count() + : 0; int columnsDiscovered = schema.getTables() != null ? schema.getTables().stream().mapToInt(table -> table.getColumns() != null ? table.getColumns().size() : 0).sum() : 0; var snapshot = schemaSnapshotService.captureSnapshot(connectionId, false); + int liveUserTableCount = countLiveUserBaseTables(connectionId); + java.util.Set schemasScanned = new java.util.LinkedHashSet<>(); + if (schema.getTables() != null) { + for (var table : schema.getTables()) { + if (table.getSchema() != null && !table.getSchema().isBlank()) { + schemasScanned.add(table.getSchema()); + } + } + } + int coveragePercent = liveUserTableCount <= 0 + ? (baseTablesDiscovered > 0 ? 100 : 0) + : (int) Math.min(100, Math.round((baseTablesDiscovered * 100.0) / liveUserTableCount)); + Map details = new HashMap<>(); details.put("tablesDiscovered", tablesDiscovered); + details.put("baseTablesDiscovered", baseTablesDiscovered); details.put("columnsDiscovered", columnsDiscovered); + details.put("liveUserTableCount", liveUserTableCount); + details.put("coveragePercent", coveragePercent); + details.put("schemasScanned", new java.util.ArrayList<>(schemasScanned)); if (snapshot != null && snapshot.getCapturedAt() != null) { details.put("snapshotCapturedAt", snapshot.getCapturedAt()); } if (snapshot != null && snapshot.getSchemaHash() != null) { details.put("schemaFingerprint", snapshot.getSchemaHash()); } - details.put("method", "Evicts cached schema/object metadata, rescans the live schema, and stores a fresh snapshot"); + details.put("method", "Evicts cached schema/object metadata, rescans non-system schemas, and stores a fresh snapshot"); recordStageDetails(status, InitStage.SCHEMA_SCAN, details); + + // Coverage gate (W2b): never claim a healthy Brain when we indexed nothing + // while live user tables exist, or when coverage is badly incomplete. + if (liveUserTableCount > 0 && baseTablesDiscovered == 0) { + markFailed(status, + "Brain found 0 user tables but the database has " + liveUserTableCount + + " live base table(s). Check USAGE/SELECT grants on non-system schemas.", + taskRunId); + return null; + } + if (liveUserTableCount > 0 && coveragePercent < 80) { + markNeedsAttention(status, taskRunId, coveragePercent, baseTablesDiscovered, liveUserTableCount, schemasScanned); + return null; + } + updateProgress(status, 18, - "Scanned " + tablesDiscovered + " tables and captured a fresh schema snapshot", taskRunId); + "Scanned " + tablesDiscovered + " objects (" + baseTablesDiscovered + + "/" + Math.max(liveUserTableCount, baseTablesDiscovered) + + " base tables across " + schemasScanned.size() + " schema(s))", + taskRunId); return InitStage.DATA_SAMPLING; } + /** + * Count live non-system base tables visible to the JDBC user. Postgres uses + * the same exclusion pattern as introspection (W2a); other dialects fall + * back to discovered object counts so the gate stays a no-op. + */ + private int countLiveUserBaseTables(String connectionId) { + try { + var jdbc = connectionService.getJdbcTemplateForBackgroundJob(connectionId); + String dbType = connectionService.getDbType(connectionId); + if (dbType != null && dbType.toLowerCase().contains("postgres")) { + Integer count = jdbc.queryForObject(""" + SELECT COUNT(*)::int + FROM pg_tables t + JOIN pg_namespace n ON n.nspname = t.schemaname + JOIN pg_class c ON c.relnamespace = n.oid AND c.relname = t.tablename + WHERE t.schemaname NOT IN ('pg_catalog','information_schema','pg_toast') + AND t.schemaname NOT LIKE 'pg_temp_%' + AND t.schemaname NOT LIKE 'pg_toast_temp_%' + AND c.relkind IN ('r', 'p') + """, Integer.class); + return count != null ? count : 0; + } + } catch (Exception e) { + log.warn("Could not count live user tables for {}: {}", connectionId, e.getMessage()); + } + return 0; + } + private InitStage executeDataSampling(String connectionId, ConnectionInitStatus status, UUID taskRunId) { int tablesProfiled = 0; int columnsProfiled = 0; @@ -549,6 +617,33 @@ private void markFailed(ConnectionInitStatus status, String error, UUID taskRunI saveHistory(fresh); } + private void markNeedsAttention( + ConnectionInitStatus status, + UUID taskRunId, + int coveragePercent, + int baseTablesDiscovered, + int liveUserTableCount, + java.util.Set schemasScanned) { + var current = initStatusRepo.findById(status.getConnectionId()); + if (current.isEmpty() || !taskRunId.equals(current.get().getActiveRunId())) { + return; + } + ConnectionInitStatus fresh = current.get(); + closeActiveStage(fresh); + fresh.setCurrentStage(InitStage.NEEDS_ATTENTION); + fresh.setProgressPercent(Math.min(99, Math.max(coveragePercent, 1))); + fresh.setStageMessage( + "Indexed " + baseTablesDiscovered + "/" + liveUserTableCount + + " live base tables (" + coveragePercent + "% coverage across " + + schemasScanned.size() + " schema(s)). Fix grants or schema list, then re-initialize." + ); + fresh.setErrorMessage(null); + fresh.setCompletedAt(LocalDateTime.now()); + initStatusRepo.save(fresh); + broadcast(fresh.getConnectionId(), fresh); + saveHistory(fresh); + } + private void markCompleted(ConnectionInitStatus status, UUID taskRunId) { var current = initStatusRepo.findById(status.getConnectionId()); if (current.isEmpty() || !taskRunId.equals(current.get().getActiveRunId())) { @@ -579,7 +674,7 @@ private int stageStartPercent(InitStage stage) { case RAG_EMBEDDING -> 80; case BRAIN_ANALYSIS -> 92; case SEMANTIC_MODELING -> 96; - case COMPLETED, FAILED -> 100; + case COMPLETED, FAILED, NEEDS_ATTENTION -> 100; }; } diff --git a/backend/src/main/resources/db/migration/V110__brain_init_needs_attention.sql b/backend/src/main/resources/db/migration/V110__brain_init_needs_attention.sql new file mode 100644 index 0000000..8f18b8b --- /dev/null +++ b/backend/src/main/resources/db/migration/V110__brain_init_needs_attention.sql @@ -0,0 +1,34 @@ +-- Hand-maintained changelog (no Flyway runtime). BrainInitSchemaCompatibilityInitializer +-- also realigns these CHECKs from InitStage.values() at boot. +ALTER TABLE connection_init_status + DROP CONSTRAINT IF EXISTS connection_init_status_current_stage_check; + +ALTER TABLE connection_init_status + ADD CONSTRAINT connection_init_status_current_stage_check + CHECK (current_stage IN ( + 'SCHEMA_SCAN', 'DATA_SAMPLING', 'KEY_COLUMN_ANALYSIS', + 'COLUMN_VALUE_COLLECTION', 'INFERRED_RELATIONSHIPS', + 'SCHEMA_CLASSIFICATION', 'AI_DESCRIPTION', 'RAG_EMBEDDING', + 'BRAIN_ANALYSIS', 'SEMANTIC_MODELING', 'NEEDS_ATTENTION', + 'COMPLETED', 'FAILED' + )); + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'connection_init_history_final_stage_check' + ) THEN + ALTER TABLE connection_init_history + DROP CONSTRAINT connection_init_history_final_stage_check; + ALTER TABLE connection_init_history + ADD CONSTRAINT connection_init_history_final_stage_check + CHECK (final_stage IN ( + 'SCHEMA_SCAN', 'DATA_SAMPLING', 'KEY_COLUMN_ANALYSIS', + 'COLUMN_VALUE_COLLECTION', 'INFERRED_RELATIONSHIPS', + 'SCHEMA_CLASSIFICATION', 'AI_DESCRIPTION', 'RAG_EMBEDDING', + 'BRAIN_ANALYSIS', 'SEMANTIC_MODELING', 'NEEDS_ATTENTION', + 'COMPLETED', 'FAILED' + )); + END IF; +END $$; diff --git a/backend/src/test/java/com/dbaagent/service/CredentialServiceTelemetryTest.java b/backend/src/test/java/com/dbaagent/service/CredentialServiceTelemetryTest.java index 8d6db13..efb4973 100644 --- a/backend/src/test/java/com/dbaagent/service/CredentialServiceTelemetryTest.java +++ b/backend/src/test/java/com/dbaagent/service/CredentialServiceTelemetryTest.java @@ -2,6 +2,7 @@ import com.dbaagent.model.ConnectionRequest; import com.dbaagent.model.DatabaseConnection; +import com.dbaagent.provider.DatabaseProviderRegistry; import com.dbaagent.repository.CredentialRepository; import com.dbaagent.security.EncryptionService; import com.dbaagent.service.security.ConnectionAccessService; @@ -33,16 +34,21 @@ class CredentialServiceTelemetryTest { @Mock private EncryptionService encryptionService; @Mock private ConnectionAccessService connectionAccessService; @Mock private TelemetryClient telemetryClient; + @Mock private DatabaseProviderRegistry providerRegistry; private CredentialService service; @BeforeEach void setup() { service = new CredentialService(credentialRepository, encryptionService, - connectionAccessService, telemetryClient); + connectionAccessService, telemetryClient, providerRegistry); when(encryptionService.encrypt(any(), anyString())).thenReturn(new byte[]{1, 2, 3}); when(credentialRepository.save(any(DatabaseConnection.class))) .thenAnswer(inv -> inv.getArgument(0)); + // Telemetry dialect labels (postgres/mysql/unknown) come from normalizeDialect, + // not the canonicalizer under test elsewhere — echo the input back so these + // assertions stay about telemetry, not canonicalization. + when(providerRegistry.getCanonicalName(anyString())).thenAnswer(inv -> inv.getArgument(0)); } @Test diff --git a/index.html b/index.html index 29f3653..b0dd021 100644 --- a/index.html +++ b/index.html @@ -5,7 +5,7 @@ - DBA Agent + DeepSQL

diff --git a/scripts/local-agent-provisioner.py b/scripts/local-agent-provisioner.py index 88c5ff5..6e829fa 100755 --- a/scripts/local-agent-provisioner.py +++ b/scripts/local-agent-provisioner.py @@ -7,13 +7,21 @@ same contract so /api/agent/session can create `u-` agent profiles with MCP credentials before the Agent tab opens. -Contract (matches AgentBridgeService.callProvisioner): +Contract (matches AgentBridgeService.callProvisioner / revokeAgentTokens): POST /provision Header: X-Provision-Secret: Body: { "user": "", "token": "", "connectionId": "" } + POST /revoke + Header: X-Provision-Secret: + Body: { "user": "" } + Clears the on-disk token file and blanks DEEPSQL_AUTH_TOKEN in both the MCP + server env and the profile .env — called after a DB-side token revoke so no + stale plaintext credential keeps the agent working post-logout. + Idempotent: creates the profile on first call (cloning default), then refreshes -DEEPSQL_AUTH_TOKEN / DEEPSQL_API_BASE_URL / DEEPSQL_MCP_USER_ID in the profile .env. +DEEPSQL_TOKEN_FILE / DEEPSQL_AUTH_TOKEN / DEEPSQL_API_BASE_URL / +DEEPSQL_MCP_USER_ID in the profile .env and MCP server env. """ from __future__ import annotations @@ -39,6 +47,23 @@ def profile_for(username: str) -> str: return f"u-{safe or 'user'}" +def token_file_for(home: Path) -> Path: + return home / "deepsql.token" + + +def write_token_file(home: Path, token: str) -> Path: + """Write the MCP token atomically (temp file + rename) so the long-lived + MCP subprocess (which re-reads this file on every request, see + deepsql-phase1-lib.js readTokenFile) never observes a partially-written + token mid-rotation. 0600 — same secrecy bar as the profile .env.""" + path = token_file_for(home) + tmp = path.with_suffix(f".tmp-{os.getpid()}") + tmp.write_text((token or "") + "\n") + os.chmod(tmp, 0o600) + os.replace(tmp, path) + return path + + def ensure_profile(name: str) -> Path: home = HERMES_HOME / "profiles" / name if home.exists(): @@ -49,7 +74,7 @@ def ensure_profile(name: str) -> Path: return home -def write_profile_env(home: Path, *, user: str, token: str) -> None: +def write_profile_env(home: Path, *, user: str, token: str, token_file: Path) -> None: env_path = home / ".env" keys: dict[str, str] = {} if env_path.exists(): @@ -67,6 +92,10 @@ def write_profile_env(home: Path, *, user: str, token: str) -> None: keys["AZURE_OPENAI_KEY"] = line.split("=", 1)[1] keys["OPENAI_API_KEY"] = keys["AZURE_OPENAI_KEY"] keys["DEEPSQL_API_BASE_URL"] = API_BASE + # DEEPSQL_TOKEN_FILE is read live (mtime-checked) by the MCP subprocess, so + # a rotated token takes effect without restarting Hermes. DEEPSQL_AUTH_TOKEN + # stays as a fallback for any consumer that only reads the env snapshot. + keys["DEEPSQL_TOKEN_FILE"] = str(token_file) keys["DEEPSQL_AUTH_TOKEN"] = token or "" keys["DEEPSQL_MCP_USER_ID"] = user keys["DEEPSQL_MCP_PROJECT_ID"] = "deepsql-agent" @@ -100,13 +129,16 @@ def _load_profile_config(home: Path): return cfg if isinstance(cfg, dict) else {} -def write_profile_mcp(home: Path, *, user: str, token: str) -> None: +def write_profile_mcp(home: Path, *, user: str, token: str, token_file: Path) -> None: import yaml # agent venv / system PyYAML cfg_path = home / "config.yaml" cfg = _load_profile_config(home) # Token must live on the MCP subprocess env — the agent runtime does not auto-forward - # the profile .env into mcp_servers.*.env. + # the profile .env into mcp_servers.*.env. DEEPSQL_TOKEN_FILE lets the MCP + # process pick up a rotated token live (mtime-checked re-read) without + # Hermes respawning the subprocess; DEEPSQL_AUTH_TOKEN is kept as a + # fallback for the env-snapshot path. cfg.setdefault("mcp_servers", {})["deepsql"] = { "command": "node", "args": [str(REPO_ROOT / "mcp" / "deepsql-phase1-server.js")], @@ -114,6 +146,7 @@ def write_profile_mcp(home: Path, *, user: str, token: str) -> None: "DEEPSQL_API_BASE_URL": API_BASE, "DEEPSQL_MCP_USER_ID": user, "DEEPSQL_MCP_PROJECT_ID": "deepsql-agent", + "DEEPSQL_TOKEN_FILE": str(token_file), "DEEPSQL_AUTH_TOKEN": token or "", }, } @@ -151,8 +184,14 @@ def do_GET(self): return self._send(404, {"error": "not found"}) def do_POST(self): - if self.path.rstrip("/") != "/provision": - return self._send(404, {"error": "not found"}) + path = self.path.rstrip("/") + if path == "/provision": + return self._handle_provision() + if path == "/revoke": + return self._handle_revoke() + return self._send(404, {"error": "not found"}) + + def _handle_provision(self): if not SECRET: return self._send(500, {"error": "AGENT_PROVISION_SECRET unset"}) if self.headers.get("X-Provision-Secret") != SECRET: @@ -168,8 +207,37 @@ def do_POST(self): profile = profile_for(user) try: home = ensure_profile(profile) - write_profile_mcp(home, user=user, token=token) - write_profile_env(home, user=user, token=token) + token_file = write_token_file(home, token) + write_profile_mcp(home, user=user, token=token, token_file=token_file) + write_profile_env(home, user=user, token=token, token_file=token_file) + except Exception as e: + return self._send(500, {"error": str(e)}) + return self._send(200, {"ok": True, "profile": profile, "home": str(home)}) + + def _handle_revoke(self): + """Best-effort disk cleanup on logout: blank the token file and the + DEEPSQL_AUTH_TOKEN fallback in both the MCP env and the profile .env + so a revoked DB token doesn't keep working via a stale plaintext copy + on disk. Does not delete the profile itself — just its credential.""" + if not SECRET: + return self._send(500, {"error": "AGENT_PROVISION_SECRET unset"}) + if self.headers.get("X-Provision-Secret") != SECRET: + return self._send(401, {"error": "unauthorized"}) + try: + body = self._read_json() + except Exception: + return self._send(400, {"error": "invalid json"}) + user = str(body.get("user") or "").strip() + if not user: + return self._send(400, {"error": "user required"}) + profile = profile_for(user) + home = HERMES_HOME / "profiles" / profile + if not home.exists(): + return self._send(200, {"ok": True, "profile": profile, "note": "no profile on disk"}) + try: + write_token_file(home, "") + write_profile_mcp(home, user=user, token="", token_file=token_file_for(home)) + write_profile_env(home, user=user, token="", token_file=token_file_for(home)) except Exception as e: return self._send(500, {"error": str(e)}) return self._send(200, {"ok": True, "profile": profile, "home": str(home)}) diff --git a/scripts/self-host/setup-agent.sh b/scripts/self-host/setup-agent.sh index 15c916a..d80ac20 100755 --- a/scripts/self-host/setup-agent.sh +++ b/scripts/self-host/setup-agent.sh @@ -244,11 +244,23 @@ cfg = cfg or {} repo = os.environ["REPO_ROOT"] port = os.environ["BACKEND_PORT"] token = os.environ["TOKEN"] + +# Token file: written atomically (temp + rename) so the long-lived MCP +# subprocess (mtime-checked re-read, see deepsql-phase1-lib.js readTokenFile) +# never observes a partial write mid-rotation. DEEPSQL_AUTH_TOKEN stays as a +# fallback for any consumer that only reads the env snapshot. +token_file = home / "deepsql.token" +tmp = token_file.with_suffix(f".tmp-{os.getpid()}") +tmp.write_text(token + "\n") +tmp.chmod(0o600) +os.replace(tmp, token_file) + cfg.setdefault("mcp_servers", {})["deepsql"] = { "command": "node", "args": [f"{repo}/mcp/deepsql-phase1-server.js"], "env": { "DEEPSQL_API_BASE_URL": f"http://localhost:{port}/api/", + "DEEPSQL_TOKEN_FILE": str(token_file), "DEEPSQL_AUTH_TOKEN": token, "DEEPSQL_MCP_USER_ID": os.environ["PROFILE"], "DEEPSQL_MCP_PROJECT_ID": os.environ["PROFILE"], @@ -260,6 +272,7 @@ cfg_path.write_text(yaml.safe_dump(cfg, sort_keys=False)) env_path = home / ".env" env_path.write_text( f"DEEPSQL_API_BASE_URL=http://localhost:{port}/api/\n" + f"DEEPSQL_TOKEN_FILE={token_file}\n" f"DEEPSQL_AUTH_TOKEN={token}\n" f"DEEPSQL_MCP_USER_ID={os.environ['PROFILE']}\n" f"DEEPSQL_MCP_PROJECT_ID={os.environ['PROFILE']}\n" diff --git a/src/App.jsx b/src/App.jsx index 3790c07..bdd7a2a 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -5,6 +5,7 @@ import Login from './pages/Login' import Signup from './pages/Signup' import ActivateInvite from './pages/ActivateInvite' import CliAuthorize from './pages/CliAuthorize' +import Onboarding from './pages/Onboarding' import PublicDashboardPage from './pages/PublicDashboardPage' import SharedDashboardPage from './pages/SharedDashboardPage' import { Component } from 'react' @@ -123,6 +124,15 @@ function App() { } /> + {/* First-run setup wizard — add a connection, configure the LLM, kick Brain init. */} + + + + } + /> {/* Public shared dashboard — no login; the token is the authorization. */} } /> {/* Internal deep link to one dashboard (login + access required). */} @@ -134,8 +144,8 @@ function App() { } /> - {/* Legacy /setup route — redirect to dashboard */} - } /> + {/* Legacy /setup route — now the real onboarding wizard, not a dead end. */} + } /> } /> } /> diff --git a/src/components/AgentChat/AgentChatPanel.jsx b/src/components/AgentChat/AgentChatPanel.jsx index ba140bd..1a12733 100644 --- a/src/components/AgentChat/AgentChatPanel.jsx +++ b/src/components/AgentChat/AgentChatPanel.jsx @@ -30,16 +30,25 @@ function deriveTitle(messages) { return firstUser.content.replace(/\s+/g, ' ').trim().slice(0, 80) } +// Generic, schema-agnostic prompts — must work for any connection (booking +// systems, SaaS multi-tenant DBs, analytics warehouses, ...). Never hardcode +// a domain-specific table/column name here (see the chat guardrail in +// AGENTS.md); AgentChatPanel has no idea what tables the active connection has. const SUGGESTIONS = [ - 'How many bookings are in this database?', - 'What does this database track, and the 5 largest tables?', - 'What indexes should I add or drop to speed things up?', + 'How many tables are there?', + 'Show the largest tables', + 'What are the top slow queries?', ] export default function AgentChatPanel({ connectionId, connectionName }) { const [sessionId, setSessionId] = useState(null) const [booting, setBooting] = useState(true) const [bootError, setBootError] = useState(null) + // True once /api/agent/session comes back with mcpAuthOk===false — the + // freshly provisioned MCP token can't reach DeepSQL's API. Chat must stay + // blocked until a retry goes green (W1: fail loud before the first message, + // not six tool-call failures in). + const [authBlocked, setAuthBlocked] = useState(false) const [messages, setMessages] = useState([]) const [input, setInput] = useState('') const [sending, setSending] = useState(false) @@ -52,12 +61,18 @@ export default function AgentChatPanel({ connectionId, connectionName }) { const restoredRef = useRef(false) // guards the persist effect until boot finishes const boot = useCallback(async ({ fresh = false } = {}) => { - setBooting(true); setBootError(null) + setBooting(true); setBootError(null); setAuthBlocked(false) restoredRef.current = false convIdRef.current = null esRef.current?.close(); esRef.current = null try { - const { profile } = await agentChatAPI.bootstrap(connectionId) + const { profile, mcpAuthOk, mcpAuthError } = await agentChatAPI.bootstrap(connectionId) + if (mcpAuthOk === false) { + setAuthBlocked(true) + setBootError(mcpAuthError || 'Agent cannot reach DeepSQL (auth). Reconnect / check Agent runtime.') + setBooting(false) + return + } profileRef.current = profile // Hermes requires the hermes_profile cookie before session/chat calls; // without it, chat/start 404s and the UI loader never resolves. @@ -116,7 +131,7 @@ export default function AgentChatPanel({ connectionId, connectionName }) { const send = async (preset) => { const text = (preset ?? input).trim() - if (!text || sending || !sessionId) return + if (!text || sending || !sessionId || authBlocked) return setInput('') setMessages((m) => [...m, { role: 'user', content: text }, { role: 'assistant', content: '', tools: [], streaming: true }]) setSending(true) @@ -221,17 +236,17 @@ export default function AgentChatPanel({ connectionId, connectionName }) {