From 0899d37b4d5cbb3065691aa02ef4093f9ea7bb9e Mon Sep 17 00:00:00 2001 From: Krishna Sasank Talasila <606482+geekypunk@users.noreply.github.com> Date: Sat, 22 Aug 2026 17:49:45 +0000 Subject: [PATCH] fix(brain): unwedge suggestion approval and refresh knowledge counts live MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Approving code-scan suggestions reported "Approved 0 of 2" and, once approving worked, the knowledge counts stayed stale until a page reload while freshly approved items sorted to the bottom of the list. Approval was permanently wedged ------------------------------ `schema_documentation` carried no unique constraint on (connection_id, object_type, object_name, parent_object, source) and `CodeSuggestionApplier.approve` took no row lock, so one bulk approve submitted twice concurrently wrote 219 duplicate pairs. Every later approve touching one of those keys threw `IncorrectResultSizeDataAccessException: Query did not return a unique result` out of the Optional-returning upsert finder, which bulk-decide swallowed per item. The duplicate never self-heals, so all 198 pending SCHEMA_DOC suggestions were unapprovable. - Those finders now return List; `SchemaDocumentationDeduplicator` collapses matches, keeps the newest row, repoints any `applied_doc_id` off the rows it deletes (a loose reference, not an FK, so a dangling value fails silently) and drops their RAG embeddings. Applied at all four call sites, including `SchemaDriftListener`. - `V116__dedupe_schema_documentation.sql` + `SchemaDocumentationDedupeInitializer` remove the duplicates and add `ux_schema_doc_target`. There is no Flyway here, so the initializer is what actually applies it; it is idempotent and skips once the index exists. On the reporting install: 219 rows and 219 orphaned embeddings removed, 0 duplicate groups left. - approve/reject load the suggestion `FOR UPDATE`, so the concurrent double-submit that created the duplicates blocks instead of racing. Counts did not refresh ---------------------- An approval also writes `schema_documentation`, served by `brain/notes`, which backs the Write-notes tab and its coverage counts. The decide hooks invalidated only codeScan + companyKnowledge, so those counts were stale until reload. `invalidateAfterDecision` now covers brain and schemaContext too. Newest items sorted last ------------------------ - `@PreUpdate` does not fire on insert, so a new note has a null `updatedAt`; sorting on it alone with nulls last sent every brand-new note to the bottom. `BrainNoteService` sorts on COALESCE(updatedAt, createdAt), matching the company-knowledge repo. - `listSuggestions` sorted every status by confidence, scattering a fresh approval among hundreds of older ones. PENDING stays confidence-first (a work queue); decided statuses sort by `decidedAt DESC NULLS LAST`. Test tooling ------------ Both self-host scripts hardcoded `sudo -u postgres psql`, which does not exist on the Compose deployment install.sh produces, so the documented verify command failed before testing anything; they now resolve the path via `scripts/self-host/vaultdb.py`. The e2e suite also parked every real CODE_DERIVED row by rewriting source to USER and never restored it, silently relabelling approved docs on a live install — it now copies rows to a scratch table and restores them, and only deletes its planted row while nothing references it. Added cases for duplicate collapse, applied_doc_id repointing, the unique index, and two genuinely concurrent approves writing exactly one row: 30/30 pass. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018mG2xj9gWJ8WzfDP2fDePP --- .gitignore | 4 + CLAUDE.md | 57 ++++ .../SchemaDocumentationDedupeInitializer.java | 116 ++++++++ .../CodeKnowledgeSuggestionRepository.java | 30 +++ .../SchemaDocumentationRepository.java | 17 +- .../service/SchemaDescriptionService.java | 25 +- .../SchemaDocumentationDeduplicator.java | 93 +++++++ .../dbaagent/service/SchemaDriftListener.java | 11 +- .../service/brain/core/BrainNoteService.java | 17 +- .../service/codescan/CodeScanService.java | 26 +- .../codescan/CodeSuggestionApplier.java | 27 +- .../V116__dedupe_schema_documentation.sql | 53 ++++ .../service/SchemaDescriptionServiceTest.java | 8 +- .../SchemaDriftListenerColumnsTest.java | 2 +- .../core/BrainNoteServiceOrderingTest.java | 98 +++++++ .../codescan/CodeSuggestionApplierTest.java | 149 ++++++++++- scripts/self-host/e2e-review-approvals.py | 249 +++++++++++++++--- scripts/self-host/seed-review-suggestions.py | 43 ++- scripts/self-host/vaultdb.py | 55 ++++ src/lib/hooks/queries/useCodeScan.js | 31 ++- 20 files changed, 1028 insertions(+), 83 deletions(-) create mode 100644 backend/src/main/java/com/dbaagent/config/SchemaDocumentationDedupeInitializer.java create mode 100644 backend/src/main/java/com/dbaagent/service/SchemaDocumentationDeduplicator.java create mode 100644 backend/src/main/resources/db/migration/V116__dedupe_schema_documentation.sql create mode 100644 backend/src/test/java/com/dbaagent/service/brain/core/BrainNoteServiceOrderingTest.java create mode 100644 scripts/self-host/vaultdb.py diff --git a/.gitignore b/.gitignore index 33c4e76..e08ba35 100644 --- a/.gitignore +++ b/.gitignore @@ -104,3 +104,7 @@ optd-sidecar/target/ *.iml .local-admin-credentials .local-mcp-token + +# Python bytecode from scripts/ +__pycache__/ +*.pyc diff --git a/CLAUDE.md b/CLAUDE.md index b046d0e..e261416 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -287,6 +287,16 @@ broken. Assert the *outcome*, never the attempt: - **Mocks hide SDK breaks.** `tests/tools/test_mcp_structured_content.py` uses a `_FakeCallToolResult` with a hardcoded `.isError`, so it kept passing precisely when the real SDK stopped matching. Pin the dependency; a fake cannot catch this. +- **A self-host verification script must reach the DB the way the install does.** + `seed-review-suggestions.py` / `e2e-review-approvals.py` hardcoded + `sudo -u postgres psql`, which only exists on a bare-metal install — on the Compose + deployment `install.sh` actually produces, the documented verify command died before + testing anything. Both now resolve the path through `scripts/self-host/vaultdb.py`. +- **A test that mutates shared state must restore it, and only what it created.** The + same e2e suite parked every real `CODE_DERIVED` row by rewriting `source` to `USER` + and never restored it, so a run against a live install silently relabelled the user's + approved docs. It now copies rows to a scratch table and restores them, and its + cleanup deletes the planted row only while nothing references it. - **Never claim a check you did not run.** `install.sh` reported "up to date" when it could not reach npm; it now says it could not check. - **`set -e` + `read` at EOF aborts silently.** Prompts in `install.sh` use @@ -375,6 +385,53 @@ it against a real database — not a theoretical hardening pass. `POST /users/admin/reset` on every install that had run `setup-agent.sh`, since that mints an admin MCP token on each run. +- **An `Optional`-returning derived finder is an assertion that the key is unique.** + Spring Data throws `IncorrectResultSizeDataAccessException` ("Query did not return a + unique result: N results were returned") the moment it is not, and the row that broke + it never repairs itself, so the failure is permanent rather than transient. + `schema_documentation` had no unique constraint on + `(connection_id, object_type, object_name, parent_object, source)` and + `CodeSuggestionApplier.approve` took no row lock, so one bulk approve submitted twice + concurrently wrote 219 duplicate pairs. Every later approve touching one of those keys + threw, `CodeScanService.bulkDecide` swallowed it per item, and the Review queue + reported "Approved 0 of 2" — with all 198 pending SCHEMA_DOC suggestions wedged. + Three-part fix, and all three are load-bearing: + 1. `V116__dedupe_schema_documentation.sql` + `SchemaDocumentationDedupeInitializer` + (no Flyway here, so the initializer is what actually applies it) collapse the + duplicates and add `ux_schema_doc_target`, keyed on + `coalesce(parent_object,'')` because Postgres treats NULLs as distinct. + 2. Those finders now return `List`, and `SchemaDocumentationDeduplicator.collapse` + keeps the newest row, repoints any `applied_doc_id` off the rows it deletes, and + drops their RAG embeddings. Do not restore an `Optional` variant — legacy installs + still carry duplicates until the initializer runs. + 3. `approve`/`reject` load the suggestion via `findByIdForUpdate` (`PESSIMISTIC_WRITE`) + so the concurrent double-submit that created the duplicates blocks instead of racing. +- **`applied_doc_id` is a loose reference, not an FK.** Deleting a `schema_documentation` + row it points at raises nothing and dangles silently — repoint before deleting. +- **Approve *updates* the row an earlier scan wrote**, so a "freshly approved" doc row + carries a historical `created_at`. A test that plants an "old" duplicate with a + hardcoded past date can easily plant the *newer* of the two and assert nothing; anchor + fixture timestamps to the real row's `created_at`. +- **A write's blast radius decides what to invalidate, not the endpoint you called.** + Approving a code-scan suggestion writes `code_knowledge_suggestion` *and* + `schema_documentation` (served by `brain/notes`, which backs the Write-notes tab + and its coverage counts) *and* `rag_documents` *and*, for KNOWLEDGE_ENTRY, a + company knowledge entry. The decide hooks invalidated only `codeScan` + + `companyKnowledge`, so every schema-doc-derived count stayed stale until the user + reloaded the page. `invalidateAfterDecision` in `useCodeScan.js` is the single + place that lists them; add to it when an approval starts writing something new. +- **`@PreUpdate` does not fire on insert, so `updatedAt` is null on a brand-new row.** + Sorting "newest first" on `updatedAt` alone with nulls last therefore sends every + freshly created row to the *bottom* — which is why a just-approved note did not + appear at the top of the Write-notes list. Sort on + `COALESCE(updatedAt, createdAt)` (`BrainNoteService.touchedAt`, + `CompanyKnowledgeEntryRepository.findByConnectionIdOrderByRecency`). +- **Suggestion list order depends on the status being viewed.** PENDING is a work + queue → `confidence DESC`. APPROVED/REJECTED are history → `decidedAt DESC NULLS + LAST` so the decision you just made is at the top; confidence-sorting a decided + list scattered fresh approvals among hundreds of older ones + (`CodeScanService.sortFor`). + ### Endpoint Authorization Rules - **Authentication is not authorization.** `SecurityConfig` only asserts diff --git a/backend/src/main/java/com/dbaagent/config/SchemaDocumentationDedupeInitializer.java b/backend/src/main/java/com/dbaagent/config/SchemaDocumentationDedupeInitializer.java new file mode 100644 index 0000000..5e7c962 --- /dev/null +++ b/backend/src/main/java/com/dbaagent/config/SchemaDocumentationDedupeInitializer.java @@ -0,0 +1,116 @@ +package com.dbaagent.config; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.DependsOn; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.transaction.support.TransactionTemplate; +import org.springframework.transaction.PlatformTransactionManager; + +import javax.sql.DataSource; + +/** + * Applies {@code V116__dedupe_schema_documentation.sql} at startup: collapses + * duplicate {@code schema_documentation} rows and adds the unique index on the + * logical key. + * + *

This repo has no Flyway runtime — {@code db/migration} is a hand-maintained + * changelog and Hibernate {@code ddl-auto=update} never adds an index the entity + * does not declare. Without this, self-host installs carrying duplicates from a + * double-submitted bulk approve stay wedged: every SCHEMA_DOC approve throws + * {@code Query did not return a unique result}. Mirrors + * {@link SchemaDocumentationSourceCompatibilityInitializer}. + * + *

Idempotent and cheap on a clean install: the index exists, so it returns + * before touching a row. + */ +@Configuration +@Slf4j +public class SchemaDocumentationDedupeInitializer { + + private static final String TABLE = "schema_documentation"; + private static final String INDEX = "ux_schema_doc_target"; + + @Bean("schemaDocumentationDedupeBootstrap") + @DependsOn("entityManagerFactory") + public Object schemaDocumentationDedupeBootstrap(DataSource dataSource, + PlatformTransactionManager txManager) { + JdbcTemplate jdbc = new JdbcTemplate(dataSource); + if (!tableExists(jdbc, TABLE)) { + return new Object(); + } + if (indexExists(jdbc, INDEX)) { + return new Object(); + } + + // One transaction: a half-applied dedupe (rows deleted, index missing) + // would silently re-accumulate duplicates until the next boot. + new TransactionTemplate(txManager).executeWithoutResult(status -> { + int repointed = jdbc.update(""" + UPDATE code_knowledge_suggestion s + SET applied_doc_id = l.keep_id + FROM (%s) l + WHERE s.applied_doc_id = l.id + """.formatted(LOSERS)); + + int embeddings = jdbc.update( + "DELETE FROM rag_documents WHERE id IN (SELECT id FROM (%s) l)".formatted(LOSERS)); + + int removed = jdbc.update( + "DELETE FROM schema_documentation WHERE id IN (SELECT id FROM (%s) l)".formatted(LOSERS)); + + jdbc.execute(""" + CREATE UNIQUE INDEX IF NOT EXISTS %s + ON %s (connection_id, object_type, object_name, coalesce(parent_object, ''), source) + """.formatted(INDEX, TABLE)); + + if (removed > 0) { + log.warn("Deduped {}: removed {} duplicate rows, {} orphaned embeddings, " + + "repointed {} applied_doc_id references", + TABLE, removed, embeddings, repointed); + } + log.info("Ensured unique index {} on {}", INDEX, TABLE); + }); + return new Object(); + } + + /** + * Every row but the newest within each logical key. Newest wins because it is + * the row existing {@code applied_doc_id} references point at; {@code id} + * breaks ties for rows written in the same clock tick. {@code coalesce} on + * {@code parent_object} because Postgres treats NULLs as distinct, so TABLE + * rows would otherwise never group together. + */ + private static final String LOSERS = """ + SELECT id, keep_id FROM ( + SELECT id, + first_value(id) OVER w AS keep_id, + row_number() OVER w AS rn + FROM schema_documentation + WINDOW w AS ( + PARTITION BY connection_id, object_type, object_name, + coalesce(parent_object, ''), source + ORDER BY created_at DESC NULLS LAST, id DESC + ) + ) ranked WHERE rn > 1 + """; + + private boolean tableExists(JdbcTemplate jdbc, String tableName) { + Integer count = jdbc.queryForObject(""" + SELECT COUNT(*) + FROM information_schema.tables + WHERE table_schema = 'public' AND table_name = ? + """, Integer.class, tableName); + return count != null && count > 0; + } + + private boolean indexExists(JdbcTemplate jdbc, String indexName) { + Integer count = jdbc.queryForObject(""" + SELECT COUNT(*) + FROM pg_indexes + WHERE schemaname = 'public' AND indexname = ? + """, Integer.class, indexName); + return count != null && count > 0; + } +} diff --git a/backend/src/main/java/com/dbaagent/repository/CodeKnowledgeSuggestionRepository.java b/backend/src/main/java/com/dbaagent/repository/CodeKnowledgeSuggestionRepository.java index f75bf44..4111315 100644 --- a/backend/src/main/java/com/dbaagent/repository/CodeKnowledgeSuggestionRepository.java +++ b/backend/src/main/java/com/dbaagent/repository/CodeKnowledgeSuggestionRepository.java @@ -3,14 +3,44 @@ import com.dbaagent.model.code.CodeKnowledgeSuggestion; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import jakarta.persistence.LockModeType; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; +import java.util.Collection; import java.util.List; +import java.util.Optional; @Repository public interface CodeKnowledgeSuggestionRepository extends JpaRepository { + /** + * Row-locking load used by approve/reject. Without it two concurrent bulk + * decides both read the same suggestion as PENDING and both materialize a + * {@code schema_documentation} row — the duplicate-row bug that + * {@code V116__dedupe_schema_documentation.sql} had to clean up. The second + * caller now blocks, then sees APPROVED and returns early. + */ + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT s FROM CodeKnowledgeSuggestion s WHERE s.id = :id") + Optional findByIdForUpdate(@Param("id") String id); + + /** + * Repoints approvals at the surviving row when duplicate schema_documentation + * rows are collapsed. {@code applied_doc_id} is a loose reference, not an FK, + * so deleting a duplicate would otherwise leave a suggestion pointing at a row + * that no longer exists — silently, since nothing enforces it. + */ + @Modifying(flushAutomatically = true) + @Query("UPDATE CodeKnowledgeSuggestion s SET s.appliedDocId = :keepId " + + "WHERE s.appliedDocId IN :staleIds") + int repointAppliedDocId(@Param("keepId") String keepId, + @Param("staleIds") Collection staleIds); + Page findByConnectionIdAndStatus( String connectionId, CodeKnowledgeSuggestion.Status status, diff --git a/backend/src/main/java/com/dbaagent/repository/SchemaDocumentationRepository.java b/backend/src/main/java/com/dbaagent/repository/SchemaDocumentationRepository.java index bd23af1..1ed99e8 100644 --- a/backend/src/main/java/com/dbaagent/repository/SchemaDocumentationRepository.java +++ b/backend/src/main/java/com/dbaagent/repository/SchemaDocumentationRepository.java @@ -21,7 +21,15 @@ List findByConnectionIdAndObjectType( SchemaDocumentation.DocumentationType objectType ); - Optional findByConnectionIdAndObjectTypeAndObjectName( + /** + * Returns a {@link List}, never an {@link Optional} — the logical key is not + * unique in data written before {@code V116__dedupe_schema_documentation.sql} + * added the constraint, and an {@code Optional} finder throws + * {@code IncorrectResultSizeDataAccessException} on a legacy duplicate rather + * than letting the caller repair it. Collapse matches with + * {@link com.dbaagent.service.SchemaDocumentationDeduplicator}. + */ + List findByConnectionIdAndObjectTypeAndObjectName( String connectionId, SchemaDocumentation.DocumentationType objectType, String objectName @@ -51,12 +59,13 @@ AND TRIM(d.businessTerms) <> '' """) long countWithBusinessTerms(@Param("connectionId") String connectionId); - // Upsert support: find existing AI doc to update instead of creating duplicates - Optional findByConnectionIdAndObjectTypeAndObjectNameAndSource( + // Upsert support: find existing doc to update instead of creating duplicates. + // List-returning for the same reason as findByConnectionIdAndObjectTypeAndObjectName above. + List findByConnectionIdAndObjectTypeAndObjectNameAndSource( String connectionId, SchemaDocumentation.DocumentationType objectType, String objectName, DocumentationSource source); - Optional findByConnectionIdAndObjectTypeAndObjectNameAndParentObjectAndSource( + List findByConnectionIdAndObjectTypeAndObjectNameAndParentObjectAndSource( String connectionId, SchemaDocumentation.DocumentationType objectType, String objectName, String parentObject, DocumentationSource source); diff --git a/backend/src/main/java/com/dbaagent/service/SchemaDescriptionService.java b/backend/src/main/java/com/dbaagent/service/SchemaDescriptionService.java index 19d82f8..7b32756 100644 --- a/backend/src/main/java/com/dbaagent/service/SchemaDescriptionService.java +++ b/backend/src/main/java/com/dbaagent/service/SchemaDescriptionService.java @@ -34,6 +34,7 @@ public class SchemaDescriptionService { private final ColumnProfileRepository columnProfileRepo; private final InferredTableRelationshipRepository inferredRelationshipRepository; private final TrainingService trainingService; + private final SchemaDocumentationDeduplicator schemaDocDeduplicator; private final ConnectionService connectionService; private final DatabaseProviderRegistry providerRegistry; private final ObjectMapper objectMapper = new ObjectMapper(); @@ -71,6 +72,7 @@ public SchemaDescriptionService( ColumnProfileRepository columnProfileRepo, InferredTableRelationshipRepository inferredRelationshipRepository, TrainingService trainingService, + SchemaDocumentationDeduplicator schemaDocDeduplicator, ConnectionService connectionService, DatabaseProviderRegistry providerRegistry, @Value("${brain.description.ai-concurrency:4}") int aiConcurrency) { @@ -80,6 +82,7 @@ public SchemaDescriptionService( this.columnProfileRepo = columnProfileRepo; this.inferredRelationshipRepository = inferredRelationshipRepository; this.trainingService = trainingService; + this.schemaDocDeduplicator = schemaDocDeduplicator; this.connectionService = connectionService; this.providerRegistry = providerRegistry; this.aiConcurrency = Math.max(1, aiConcurrency); @@ -417,13 +420,14 @@ private int saveTableDescription(String connectionId, TableDescription desc, : desc.getTableName(); // Upsert table-level doc (find existing AI doc or create new) - var existingTableDoc = schemaDocRepo - .findByConnectionIdAndObjectTypeAndObjectNameAndSource( + var existingTableDoc = schemaDocDeduplicator.collapse( + schemaDocRepo.findByConnectionIdAndObjectTypeAndObjectNameAndSource( connectionId, DocumentationType.TABLE, objectName, - DocumentationSource.AI_GENERATED); + DocumentationSource.AI_GENERATED), + objectName + " (TABLE, AI_GENERATED)"); SchemaDocumentation tableDoc; - if (existingTableDoc.isPresent()) { - tableDoc = existingTableDoc.get(); + if (existingTableDoc != null) { + tableDoc = existingTableDoc; tableDoc.setDescription(desc.getTableDescription()); tableDoc.setBusinessTerms(desc.getBusinessTerms()); tableDoc.setConfidence(desc.getConfidence()); @@ -445,13 +449,14 @@ private int saveTableDescription(String connectionId, TableDescription desc, // Upsert column-level docs for (var col : desc.getColumns()) { if (col.getDescription() == null || col.getDescription().isBlank()) continue; - var existingColDoc = schemaDocRepo - .findByConnectionIdAndObjectTypeAndObjectNameAndParentObjectAndSource( + var existingColDoc = schemaDocDeduplicator.collapse( + schemaDocRepo.findByConnectionIdAndObjectTypeAndObjectNameAndParentObjectAndSource( connectionId, DocumentationType.COLUMN, col.getName(), - objectName, DocumentationSource.AI_GENERATED); + objectName, DocumentationSource.AI_GENERATED), + objectName + "." + col.getName() + " (COLUMN, AI_GENERATED)"); SchemaDocumentation colDoc; - if (existingColDoc.isPresent()) { - colDoc = existingColDoc.get(); + if (existingColDoc != null) { + colDoc = existingColDoc; colDoc.setDescription(col.getDescription()); colDoc.setConfidence(col.getConfidence()); } else { diff --git a/backend/src/main/java/com/dbaagent/service/SchemaDocumentationDeduplicator.java b/backend/src/main/java/com/dbaagent/service/SchemaDocumentationDeduplicator.java new file mode 100644 index 0000000..b34a2d4 --- /dev/null +++ b/backend/src/main/java/com/dbaagent/service/SchemaDocumentationDeduplicator.java @@ -0,0 +1,93 @@ +package com.dbaagent.service; + +import com.dbaagent.model.SchemaDocumentation; +import com.dbaagent.repository.CodeKnowledgeSuggestionRepository; +import com.dbaagent.repository.SchemaDocumentationRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.Comparator; +import java.util.List; + +/** + * Collapses duplicate {@link SchemaDocumentation} rows that share a logical key. + * + *

{@code schema_documentation} carried no unique constraint on + * {@code (connection_id, object_type, object_name, parent_object, source)} until + * {@code V116__dedupe_schema_documentation.sql}, and + * {@link com.dbaagent.service.codescan.CodeSuggestionApplier#approve} took no row + * lock — so a bulk approve submitted twice concurrently wrote two identical rows + * per suggestion. Every later upsert against that key then threw + * {@code IncorrectResultSizeDataAccessException: Query did not return a unique + * result}, which {@code CodeScanService.bulkDecide} swallowed into + * "Approved 0 of N". The duplicate never self-heals, so the suggestion stays + * permanently unapprovable. + * + *

Repairing on read is what unwedges installs whose duplicates predate the + * constraint: keep the newest row (the one existing {@code applied_doc_id} + * references), drop the rest along with their RAG embeddings. + */ +@Component +@RequiredArgsConstructor +@Slf4j +public class SchemaDocumentationDeduplicator { + + private final SchemaDocumentationRepository schemaDocRepository; + private final CodeKnowledgeSuggestionRepository suggestionRepository; + private final TrainingService trainingService; + + /** + * Reduces {@code matches} to at most one row, deleting any extras. + * + * @return the surviving row, or {@code null} when {@code matches} is empty + * (caller creates a fresh row). + */ + @Transactional + public SchemaDocumentation collapse(List matches, String context) { + if (matches == null || matches.isEmpty()) { + return null; + } + if (matches.size() == 1) { + return matches.get(0); + } + + // Newest wins: it is the row a prior approval linked via applied_doc_id, + // so keeping it preserves those references. id breaks ties on rows + // written inside the same clock tick. + List ordered = matches.stream() + .sorted(Comparator + .comparing(SchemaDocumentation::getCreatedAt, + Comparator.nullsFirst(Comparator.naturalOrder())) + .thenComparing(SchemaDocumentation::getId, + Comparator.nullsFirst(Comparator.naturalOrder()))) + .toList(); + + SchemaDocumentation survivor = ordered.get(ordered.size() - 1); + List stale = ordered.subList(0, ordered.size() - 1); + log.warn("Collapsing {} duplicate schema_documentation rows for {} — keeping {}", + ordered.size(), context, survivor.getId()); + + // Which row wins is a heuristic; leaving another approval pointing at a + // deleted id is not acceptable either way, so repoint before deleting. + int repointed = suggestionRepository.repointAppliedDocId( + survivor.getId(), stale.stream().map(SchemaDocumentation::getId).toList()); + if (repointed > 0) { + log.info("Repointed {} applied_doc_id reference(s) to {}", repointed, survivor.getId()); + } + + for (SchemaDocumentation doc : stale) { + try { + trainingService.deleteDocumentationEmbedding(doc.getConnectionId(), doc.getId()); + } catch (Exception e) { + // A stranded embedding degrades retrieval; a failed delete must not + // block the approval the caller is in the middle of. + log.warn("Failed to delete embedding for duplicate doc {}: {}", doc.getId(), e.getMessage()); + } + schemaDocRepository.delete(doc); + } + return survivor; + } +} diff --git a/backend/src/main/java/com/dbaagent/service/SchemaDriftListener.java b/backend/src/main/java/com/dbaagent/service/SchemaDriftListener.java index 29b33fd..b86ef9e 100644 --- a/backend/src/main/java/com/dbaagent/service/SchemaDriftListener.java +++ b/backend/src/main/java/com/dbaagent/service/SchemaDriftListener.java @@ -78,13 +78,14 @@ public void onTablesRemoved(String connectionId, List sampleRemovedTable // Delete table-level doc (if AI-generated). Now also cascade // the embedding so the RAG store doesn't keep returning hits // for a table that no longer exists. + // Iterate: the logical key was not unique before V116, so a legacy + // install can hold more than one row here and an Optional finder + // would throw instead of cleaning either of them up. schemaDocRepo.findByConnectionIdAndObjectTypeAndObjectName( connectionId, SchemaDocumentation.DocumentationType.TABLE, canonical) - .ifPresent(doc -> { - if (doc.getSource() == DocumentationSource.AI_GENERATED) { - deleteWithEmbedding(doc, "dropped table " + canonical); - } - }); + .stream() + .filter(doc -> doc.getSource() == DocumentationSource.AI_GENERATED) + .forEach(doc -> deleteWithEmbedding(doc, "dropped table " + canonical)); // Delete column-level docs for this table (if AI-generated) var columnDocs = schemaDocRepo.findByConnectionIdAndParentObject(connectionId, canonical); diff --git a/backend/src/main/java/com/dbaagent/service/brain/core/BrainNoteService.java b/backend/src/main/java/com/dbaagent/service/brain/core/BrainNoteService.java index edac370..2680f25 100644 --- a/backend/src/main/java/com/dbaagent/service/brain/core/BrainNoteService.java +++ b/backend/src/main/java/com/dbaagent/service/brain/core/BrainNoteService.java @@ -34,12 +34,27 @@ public List getNotes(String connectionId, String scopeType, S List docs = schemaDocumentationRepository.findByConnectionId(connectionId); return docs.stream() .filter(doc -> matchesScope(doc, scopeType, tableName, columnName)) - .sorted(Comparator.comparing(SchemaDocumentation::getUpdatedAt, + .sorted(Comparator.comparing(BrainNoteService::touchedAt, Comparator.nullsLast(Comparator.reverseOrder()))) .map(doc -> toResponse(doc, lastSnapshotAt)) .collect(Collectors.toList()); } + /** + * Recency for sorting: {@code updatedAt} falls back to {@code createdAt}. + * + *

{@code @PreUpdate} never fires on an insert, so a note that has only ever + * been created has a null {@code updatedAt}. Sorting on {@code updatedAt} alone + * with nulls last therefore pushed every brand-new note to the *bottom* — the + * opposite of what "newest first" means, and why a freshly approved code-scan + * suggestion did not show up at the top of the list. Same + * {@code COALESCE(updatedAt, createdAt)} convention as + * {@code CompanyKnowledgeEntryRepository.findByConnectionIdOrderByRecency}. + */ + private static LocalDateTime touchedAt(SchemaDocumentation doc) { + return doc.getUpdatedAt() != null ? doc.getUpdatedAt() : doc.getCreatedAt(); + } + public BrainNoteResponse createNote(BrainNoteRequest request) { validateRequest(request, true); SchemaDocumentation doc = SchemaDocumentation.builder() diff --git a/backend/src/main/java/com/dbaagent/service/codescan/CodeScanService.java b/backend/src/main/java/com/dbaagent/service/codescan/CodeScanService.java index e9e0513..5b4a6da 100644 --- a/backend/src/main/java/com/dbaagent/service/codescan/CodeScanService.java +++ b/backend/src/main/java/com/dbaagent/service/codescan/CodeScanService.java @@ -553,8 +553,30 @@ public Page listSuggestions(String connectionId, CodeKnowledgeSuggestion.Status status, int page, int size) { - var pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "confidence", "createdAt")); - return suggestionRepository.findByConnectionIdAndStatus(connectionId, status, pageable); + return suggestionRepository.findByConnectionIdAndStatus( + connectionId, status, PageRequest.of(page, size, sortFor(status))); + } + + /** + * Sort depends on what the reviewer is looking at. + * + *

PENDING is a work queue — highest confidence first, so the best candidates + * are the ones you see. Anything already decided is a history view, and the + * question there is "what did I just do", so it leads with the most recent + * decision. Sorting decided rows by confidence scattered a fresh approval + * somewhere in the middle of hundreds of older ones. + * + *

{@code decidedAt} is null on rows decided before it was recorded, hence + * nullsLast with a createdAt fallback. + */ + private static Sort sortFor(CodeKnowledgeSuggestion.Status status) { + if (status == CodeKnowledgeSuggestion.Status.PENDING) { + return Sort.by(Sort.Direction.DESC, "confidence", "createdAt"); + } + return Sort.by( + new Sort.Order(Sort.Direction.DESC, "decidedAt", Sort.NullHandling.NULLS_LAST), + new Sort.Order(Sort.Direction.DESC, "createdAt") + ); } /** diff --git a/backend/src/main/java/com/dbaagent/service/codescan/CodeSuggestionApplier.java b/backend/src/main/java/com/dbaagent/service/codescan/CodeSuggestionApplier.java index 174bf64..b77a781 100644 --- a/backend/src/main/java/com/dbaagent/service/codescan/CodeSuggestionApplier.java +++ b/backend/src/main/java/com/dbaagent/service/codescan/CodeSuggestionApplier.java @@ -7,6 +7,7 @@ import com.dbaagent.repository.CodeKnowledgeSuggestionRepository; import com.dbaagent.repository.SchemaDocumentationRepository; import com.dbaagent.service.CompanyKnowledgeService; +import com.dbaagent.service.SchemaDocumentationDeduplicator; import com.dbaagent.service.SchemaScannerService; import com.dbaagent.service.TrainingService; import lombok.RequiredArgsConstructor; @@ -17,7 +18,6 @@ import java.time.LocalDateTime; import java.util.List; import java.util.Map; -import java.util.Optional; /** * Materializes an APPROVED suggestion into a real @@ -35,10 +35,11 @@ public class CodeSuggestionApplier { private final CompanyKnowledgeService companyKnowledgeService; private final TrainingService trainingService; private final SchemaScannerService schemaScannerService; + private final SchemaDocumentationDeduplicator schemaDocDeduplicator; @Transactional public CodeKnowledgeSuggestion approve(String suggestionId, String decidedBy, String note) { - CodeKnowledgeSuggestion suggestion = suggestionRepository.findById(suggestionId) + CodeKnowledgeSuggestion suggestion = suggestionRepository.findByIdForUpdate(suggestionId) .orElseThrow(() -> new IllegalArgumentException("Suggestion not found: " + suggestionId)); if (suggestion.getStatus() == CodeKnowledgeSuggestion.Status.APPROVED) { return suggestion; @@ -62,7 +63,7 @@ public CodeKnowledgeSuggestion approve(String suggestionId, String decidedBy, St @Transactional public CodeKnowledgeSuggestion reject(String suggestionId, String decidedBy, String note) { - CodeKnowledgeSuggestion suggestion = suggestionRepository.findById(suggestionId) + CodeKnowledgeSuggestion suggestion = suggestionRepository.findByIdForUpdate(suggestionId) .orElseThrow(() -> new IllegalArgumentException("Suggestion not found: " + suggestionId)); if (suggestion.getStatus() == CodeKnowledgeSuggestion.Status.REJECTED) { return suggestion; @@ -112,11 +113,14 @@ private void applySchemaDoc(CodeKnowledgeSuggestion suggestion, String decidedBy } // Scope the upsert to our own CODE_DERIVED rows AND, for columns, - // include parent_object — same column name can exist in many tables, - // so a (connection, COLUMN, "status", CODE_DERIVED) lookup would - // otherwise return multiple rows and throw "Query did not return a - // unique result". - Optional existing; + // include parent_object — the same column name exists in many tables, so a + // (connection, COLUMN, "status", CODE_DERIVED) lookup would match all of them. + // + // Even with the full key this can match more than one row on installs whose + // duplicates predate V116's unique index, so collapse rather than assume one: + // an Optional finder here threw "Query did not return a unique result" and + // wedged the suggestion forever. + List existing; if (objectType == SchemaDocumentation.DocumentationType.COLUMN) { existing = schemaDocRepository .findByConnectionIdAndObjectTypeAndObjectNameAndParentObjectAndSource( @@ -136,7 +140,12 @@ private void applySchemaDoc(CodeKnowledgeSuggestion suggestion, String decidedBy ); } - SchemaDocumentation doc = existing.orElseGet(SchemaDocumentation::new); + String docKey = (parentObject == null ? objectName : parentObject + "." + objectName) + + " (" + objectType + ", CODE_DERIVED)"; + SchemaDocumentation doc = schemaDocDeduplicator.collapse(existing, docKey); + if (doc == null) { + doc = new SchemaDocumentation(); + } doc.setConnectionId(suggestion.getConnectionId()); doc.setObjectType(objectType); doc.setObjectName(objectName); diff --git a/backend/src/main/resources/db/migration/V116__dedupe_schema_documentation.sql b/backend/src/main/resources/db/migration/V116__dedupe_schema_documentation.sql new file mode 100644 index 0000000..4fd28b5 --- /dev/null +++ b/backend/src/main/resources/db/migration/V116__dedupe_schema_documentation.sql @@ -0,0 +1,53 @@ +-- Collapse duplicate schema_documentation rows and make the logical key unique. +-- +-- schema_documentation never carried a unique constraint on +-- (connection_id, object_type, object_name, parent_object, source), and +-- CodeSuggestionApplier.approve took no row lock — so a bulk approve submitted +-- twice concurrently wrote two identical rows per suggestion. Every later upsert +-- against such a key then threw +-- IncorrectResultSizeDataAccessException: Query did not return a unique result +-- which CodeScanService.bulkDecide swallowed into "Approved 0 of N", leaving the +-- whole Review queue permanently unapprovable. +-- +-- NOTE: this repo has no Flyway runtime. Applied at startup, idempotently, by +-- SchemaDocumentationDedupeInitializer — this file is the changelog of record. + +BEGIN; + +CREATE TEMP TABLE schema_doc_dupe_losers ON COMMIT DROP AS +SELECT id, keep_id +FROM ( + SELECT id, + first_value(id) OVER w AS keep_id, + row_number() OVER w AS rn + FROM schema_documentation + WINDOW w AS ( + PARTITION BY connection_id, object_type, object_name, + coalesce(parent_object, ''), source + ORDER BY created_at DESC NULLS LAST, id DESC + ) +) ranked +WHERE rn > 1; + +-- Keep the newest row: it is the one existing applied_doc_id references point at. +-- Repoint any that reference a loser before it disappears (applied_doc_id is a +-- loose reference, not a real FK, so a stale value would fail silently). +UPDATE code_knowledge_suggestion s +SET applied_doc_id = l.keep_id +FROM schema_doc_dupe_losers l +WHERE s.applied_doc_id = l.id; + +-- RAG embeddings for documentation are keyed by the doc id, so the loser's +-- vector must go with it or retrieval keeps returning the orphan. +DELETE FROM rag_documents WHERE id IN (SELECT id FROM schema_doc_dupe_losers); + +DELETE FROM schema_documentation WHERE id IN (SELECT id FROM schema_doc_dupe_losers); + +-- coalesce(parent_object, '') because Postgres treats NULLs as distinct: without +-- it, TABLE rows (parent_object IS NULL) would never collide. +CREATE UNIQUE INDEX IF NOT EXISTS ux_schema_doc_target + ON schema_documentation ( + connection_id, object_type, object_name, coalesce(parent_object, ''), source + ); + +COMMIT; diff --git a/backend/src/test/java/com/dbaagent/service/SchemaDescriptionServiceTest.java b/backend/src/test/java/com/dbaagent/service/SchemaDescriptionServiceTest.java index 47028b9..b6b07c1 100644 --- a/backend/src/test/java/com/dbaagent/service/SchemaDescriptionServiceTest.java +++ b/backend/src/test/java/com/dbaagent/service/SchemaDescriptionServiceTest.java @@ -30,6 +30,7 @@ class SchemaDescriptionServiceTest { @Mock private com.dbaagent.repository.ColumnProfileRepository columnProfileRepo; @Mock private com.dbaagent.repository.InferredTableRelationshipRepository inferredRelationshipRepository; @Mock private TrainingService trainingService; + @Mock private com.dbaagent.repository.CodeKnowledgeSuggestionRepository codeSuggestionRepo; @Mock private ConnectionService connectionService; @Mock private com.dbaagent.provider.DatabaseProviderRegistry providerRegistry; @@ -42,8 +43,9 @@ void setUp() { .thenReturn(List.of()); service = new SchemaDescriptionService( chatClientBuilder, schemaScannerService, schemaDocRepo, - columnProfileRepo, inferredRelationshipRepository, trainingService, connectionService, - providerRegistry, 4 + columnProfileRepo, inferredRelationshipRepository, trainingService, + new SchemaDocumentationDeduplicator(schemaDocRepo, codeSuggestionRepo, trainingService), + connectionService, providerRegistry, 4 ); } @@ -173,7 +175,7 @@ void forceRegenerateOverridesDeltaOnlyModeAndRegeneratesExistingAiDocs() throws .build() )); when(schemaDocRepo.findByConnectionIdAndObjectTypeAndObjectNameAndSource(any(), any(), any(), any())) - .thenReturn(Optional.empty()); + .thenReturn(List.of()); when(schemaDocRepo.save(any())).thenAnswer(i -> i.getArgument(0)); when(connectionService.isDataSamplingEnabled("conn1")).thenReturn(false); var schema = buildSchemaWithTables("users"); diff --git a/backend/src/test/java/com/dbaagent/service/SchemaDriftListenerColumnsTest.java b/backend/src/test/java/com/dbaagent/service/SchemaDriftListenerColumnsTest.java index bf7faab..94a2b00 100644 --- a/backend/src/test/java/com/dbaagent/service/SchemaDriftListenerColumnsTest.java +++ b/backend/src/test/java/com/dbaagent/service/SchemaDriftListenerColumnsTest.java @@ -94,7 +94,7 @@ void onTablesRemovedNowCleansEmbeddings() { tableDoc.setObjectType(SchemaDocumentation.DocumentationType.TABLE); when(repo.findByConnectionIdAndObjectTypeAndObjectName( "c1", SchemaDocumentation.DocumentationType.TABLE, "OLD_TABLE")) - .thenReturn(java.util.Optional.of(tableDoc)); + .thenReturn(List.of(tableDoc)); when(repo.findByConnectionIdAndParentObject("c1", "OLD_TABLE")) .thenReturn(List.of()); diff --git a/backend/src/test/java/com/dbaagent/service/brain/core/BrainNoteServiceOrderingTest.java b/backend/src/test/java/com/dbaagent/service/brain/core/BrainNoteServiceOrderingTest.java new file mode 100644 index 0000000..31c2891 --- /dev/null +++ b/backend/src/test/java/com/dbaagent/service/brain/core/BrainNoteServiceOrderingTest.java @@ -0,0 +1,98 @@ +package com.dbaagent.service.brain.core; + +import com.dbaagent.model.DocumentationSource; +import com.dbaagent.model.SchemaDocumentation; +import com.dbaagent.model.brain.BrainNoteResponse; +import com.dbaagent.repository.SchemaDocumentationRepository; +import com.dbaagent.repository.SchemaSnapshotRepository; +import com.dbaagent.service.TrainingService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +/** + * The Write-notes list is "newest first", and a note approved from the code-scan + * Review queue must land at the top of it. + */ +@ExtendWith(MockitoExtension.class) +class BrainNoteServiceOrderingTest { + + @Mock private SchemaDocumentationRepository schemaDocumentationRepository; + @Mock private SchemaSnapshotRepository schemaSnapshotRepository; + @Mock private TrainingService trainingService; + + private BrainNoteService service; + + @BeforeEach + void setUp() { + service = new BrainNoteService( + schemaDocumentationRepository, schemaSnapshotRepository, trainingService); + } + + /** + * Regression: {@code @PreUpdate} does not fire on insert, so a just-created note + * has a null {@code updatedAt}. Sorting on {@code updatedAt} with nulls last sent + * every brand-new note to the bottom of the list. + */ + @Test + void freshlyCreatedNoteSortsAboveOlderEditedOnes() { + SchemaDocumentation editedYesterday = doc( + "old_col", + LocalDateTime.of(2026, 8, 1, 9, 0), // created + LocalDateTime.of(2026, 8, 21, 9, 0)); // updated + SchemaDocumentation createdJustNow = doc( + "new_col", + LocalDateTime.of(2026, 8, 22, 17, 0), + null); // never edited + when(schemaDocumentationRepository.findByConnectionId("c1")) + .thenReturn(List.of(editedYesterday, createdJustNow)); + + List notes = service.getNotes("c1", null, null, null); + + assertThat(notes).extracting(BrainNoteResponse::getColumnName) + .containsExactly("new_col", "old_col"); + } + + @Test + void editedNoteSortsAboveNewerButUntouchedOne() { + SchemaDocumentation createdEarlier = doc( + "edited_col", + LocalDateTime.of(2026, 8, 1, 9, 0), + LocalDateTime.of(2026, 8, 22, 18, 0)); // edited most recently + SchemaDocumentation createdLater = doc( + "untouched_col", + LocalDateTime.of(2026, 8, 22, 17, 0), + null); + when(schemaDocumentationRepository.findByConnectionId("c1")) + .thenReturn(List.of(createdLater, createdEarlier)); + + List notes = service.getNotes("c1", null, null, null); + + assertThat(notes).extracting(BrainNoteResponse::getColumnName) + .containsExactly("edited_col", "untouched_col"); + } + + private static SchemaDocumentation doc(String columnName, + LocalDateTime createdAt, + LocalDateTime updatedAt) { + SchemaDocumentation doc = new SchemaDocumentation(); + doc.setId(columnName); + doc.setConnectionId("c1"); + doc.setObjectType(SchemaDocumentation.DocumentationType.COLUMN); + doc.setObjectName(columnName); + doc.setParentObject("analytics.orders"); + doc.setDescription("desc for " + columnName); + doc.setSource(DocumentationSource.CODE_DERIVED); + doc.setCreatedAt(createdAt); + doc.setUpdatedAt(updatedAt); + return doc; + } +} diff --git a/backend/src/test/java/com/dbaagent/service/codescan/CodeSuggestionApplierTest.java b/backend/src/test/java/com/dbaagent/service/codescan/CodeSuggestionApplierTest.java index 773806f..d42c208 100644 --- a/backend/src/test/java/com/dbaagent/service/codescan/CodeSuggestionApplierTest.java +++ b/backend/src/test/java/com/dbaagent/service/codescan/CodeSuggestionApplierTest.java @@ -7,6 +7,7 @@ import com.dbaagent.repository.CodeKnowledgeSuggestionRepository; import com.dbaagent.repository.SchemaDocumentationRepository; import com.dbaagent.service.CompanyKnowledgeService; +import com.dbaagent.service.SchemaDocumentationDeduplicator; import com.dbaagent.service.SchemaScannerService; import com.dbaagent.service.TrainingService; import org.junit.jupiter.api.BeforeEach; @@ -16,6 +17,7 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import java.time.LocalDateTime; import java.util.List; import java.util.Map; import java.util.Optional; @@ -24,6 +26,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -38,15 +41,20 @@ class CodeSuggestionApplierTest { @Mock private SchemaScannerService schemaScannerService; private CodeSuggestionApplier applier; + private SchemaDocumentationDeduplicator deduplicator; @BeforeEach void setUp() { + // Real deduplicator over the mocked repo: the collapse behaviour is the + // thing under test in approveSchemaDocColumn_collapsesLegacyDuplicates. + deduplicator = new SchemaDocumentationDeduplicator(schemaDocRepository, suggestionRepository, trainingService); applier = new CodeSuggestionApplier( suggestionRepository, schemaDocRepository, companyKnowledgeService, trainingService, - schemaScannerService + schemaScannerService, + deduplicator ); } @@ -57,7 +65,7 @@ void approveSchemaDocColumn_writesCodeDerivedRow() throws Exception { suggestion.setTargetObject("fct_ashram_visit.party_size"); suggestion.setPayload(Map.of("objectKind", "COLUMN", "businessTerms", List.of("party size"))); - when(suggestionRepository.findById("s1")).thenReturn(Optional.of(suggestion)); + when(suggestionRepository.findByIdForUpdate("s1")).thenReturn(Optional.of(suggestion)); when(suggestionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); when(schemaDocRepository.findByConnectionIdAndObjectTypeAndObjectNameAndParentObjectAndSource( eq("conn-1"), @@ -65,7 +73,7 @@ void approveSchemaDocColumn_writesCodeDerivedRow() throws Exception { eq("party_size"), eq("acme_erp.fct_ashram_visit"), eq(DocumentationSource.CODE_DERIVED) - )).thenReturn(Optional.empty()); + )).thenReturn(List.of()); SchemaMetadata schema = new SchemaMetadata(); schema.setDatabaseName("acme_erp"); when(schemaScannerService.scanSchema("conn-1")).thenReturn(schema); @@ -90,12 +98,143 @@ void approveSchemaDocColumn_writesCodeDerivedRow() throws Exception { verify(trainingService).upsertDocumentationEmbedding(saved); } + /** + * Regression: two identical CODE_DERIVED rows (written by a bulk approve + * submitted twice concurrently before V116's unique index existed) made the + * Optional-returning finder throw "Query did not return a unique result", + * which bulk-decide swallowed into "Approved 0 of N" forever. + */ + @Test + void approveSchemaDocColumn_collapsesLegacyDuplicates() throws Exception { + CodeKnowledgeSuggestion suggestion = baseSuggestion(); + suggestion.setTargetKind(CodeKnowledgeSuggestion.TargetKind.SCHEMA_DOC); + suggestion.setTargetObject("fct_ashram_visit.party_size"); + suggestion.setPayload(Map.of("objectKind", "COLUMN")); + + SchemaDocumentation older = duplicateRow("doc-old", LocalDateTime.of(2026, 8, 13, 17, 11, 28)); + SchemaDocumentation newer = duplicateRow("doc-new", LocalDateTime.of(2026, 8, 13, 17, 11, 31)); + + when(suggestionRepository.findByIdForUpdate("s1")).thenReturn(Optional.of(suggestion)); + when(suggestionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + when(schemaDocRepository.findByConnectionIdAndObjectTypeAndObjectNameAndParentObjectAndSource( + eq("conn-1"), + eq(SchemaDocumentation.DocumentationType.COLUMN), + eq("party_size"), + eq("acme_erp.fct_ashram_visit"), + eq(DocumentationSource.CODE_DERIVED) + )).thenReturn(List.of(older, newer)); + SchemaMetadata schema = new SchemaMetadata(); + schema.setDatabaseName("acme_erp"); + when(schemaScannerService.scanSchema("conn-1")).thenReturn(schema); + when(schemaDocRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + CodeKnowledgeSuggestion approved = applier.approve("s1", "admin", null); + + assertThat(approved.getStatus()).isEqualTo(CodeKnowledgeSuggestion.Status.APPROVED); + // Newest survives — it is the row prior approvals linked via applied_doc_id. + assertThat(approved.getAppliedDocId()).isEqualTo("doc-new"); + verify(schemaDocRepository).delete(older); + verify(schemaDocRepository, never()).delete(newer); + verify(trainingService).deleteDocumentationEmbedding("conn-1", "doc-old"); + // Another approval pointing at the deleted row must follow the survivor. + verify(suggestionRepository).repointAppliedDocId("doc-new", List.of("doc-old")); + + ArgumentCaptor captor = ArgumentCaptor.forClass(SchemaDocumentation.class); + verify(schemaDocRepository).save(captor.capture()); + assertThat(captor.getValue().getId()).isEqualTo("doc-new"); + assertThat(captor.getValue().getDescription()) + .isEqualTo("Number of people in the visiting party."); + } + + /** A failed embedding delete must not abort the approval it is cleaning up for. */ + @Test + void approveSchemaDocColumn_collapseSurvivesEmbeddingDeleteFailure() throws Exception { + CodeKnowledgeSuggestion suggestion = baseSuggestion(); + suggestion.setTargetKind(CodeKnowledgeSuggestion.TargetKind.SCHEMA_DOC); + suggestion.setTargetObject("fct_ashram_visit.party_size"); + suggestion.setPayload(Map.of("objectKind", "COLUMN")); + + SchemaDocumentation older = duplicateRow("doc-old", LocalDateTime.of(2026, 8, 13, 17, 11, 28)); + SchemaDocumentation newer = duplicateRow("doc-new", LocalDateTime.of(2026, 8, 13, 17, 11, 31)); + + when(suggestionRepository.findByIdForUpdate("s1")).thenReturn(Optional.of(suggestion)); + when(suggestionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + when(schemaDocRepository.findByConnectionIdAndObjectTypeAndObjectNameAndParentObjectAndSource( + any(), any(), any(), any(), any())).thenReturn(List.of(older, newer)); + SchemaMetadata schema = new SchemaMetadata(); + schema.setDatabaseName("acme_erp"); + when(schemaScannerService.scanSchema("conn-1")).thenReturn(schema); + when(schemaDocRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + doThrow(new RuntimeException("vector store down")) + .when(trainingService).deleteDocumentationEmbedding("conn-1", "doc-old"); + + CodeKnowledgeSuggestion approved = applier.approve("s1", "admin", null); + + assertThat(approved.getStatus()).isEqualTo(CodeKnowledgeSuggestion.Status.APPROVED); + verify(schemaDocRepository).delete(older); + } + + /** TABLE targets take the no-parent finder and must collapse the same way. */ + @Test + void approveSchemaDocTable_collapsesLegacyDuplicates() throws Exception { + CodeKnowledgeSuggestion suggestion = baseSuggestion(); + suggestion.setTargetKind(CodeKnowledgeSuggestion.TargetKind.SCHEMA_DOC); + suggestion.setTargetObject("fct_ashram_visit"); + suggestion.setPayload(Map.of("objectKind", "TABLE")); + + SchemaDocumentation older = duplicateRow("tbl-old", LocalDateTime.of(2026, 8, 13, 17, 11, 28)); + older.setObjectType(SchemaDocumentation.DocumentationType.TABLE); + older.setParentObject(null); + SchemaDocumentation newer = duplicateRow("tbl-new", LocalDateTime.of(2026, 8, 13, 17, 11, 31)); + newer.setObjectType(SchemaDocumentation.DocumentationType.TABLE); + newer.setParentObject(null); + + when(suggestionRepository.findByIdForUpdate("s1")).thenReturn(Optional.of(suggestion)); + when(suggestionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + when(schemaDocRepository.findByConnectionIdAndObjectTypeAndObjectNameAndSource( + eq("conn-1"), + eq(SchemaDocumentation.DocumentationType.TABLE), + eq("fct_ashram_visit"), + eq(DocumentationSource.CODE_DERIVED) + )).thenReturn(List.of(older, newer)); + when(schemaDocRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + CodeKnowledgeSuggestion approved = applier.approve("s1", "admin", null); + + assertThat(approved.getAppliedDocId()).isEqualTo("tbl-new"); + verify(schemaDocRepository).delete(older); + } + + /** Approve/reject must load under a row lock, else concurrent bulk decides re-create the duplicates. */ + @Test + void approveAndReject_loadTheSuggestionForUpdate() { + CodeKnowledgeSuggestion suggestion = baseSuggestion(); + suggestion.setStatus(CodeKnowledgeSuggestion.Status.APPROVED); + when(suggestionRepository.findByIdForUpdate("s1")).thenReturn(Optional.of(suggestion)); + + applier.approve("s1", "admin", null); + verify(suggestionRepository, never()).findById(any()); + } + + private static SchemaDocumentation duplicateRow(String id, LocalDateTime createdAt) { + SchemaDocumentation doc = new SchemaDocumentation(); + doc.setId(id); + doc.setConnectionId("conn-1"); + doc.setObjectType(SchemaDocumentation.DocumentationType.COLUMN); + doc.setObjectName("party_size"); + doc.setParentObject("acme_erp.fct_ashram_visit"); + doc.setSource(DocumentationSource.CODE_DERIVED); + doc.setDescription("stale"); + doc.setCreatedAt(createdAt); + return doc; + } + @Test void approveSchemaDoc_blankTarget_throwsInsteadOfSilentSkip() { CodeKnowledgeSuggestion suggestion = baseSuggestion(); suggestion.setTargetKind(CodeKnowledgeSuggestion.TargetKind.SCHEMA_DOC); suggestion.setTargetObject(" "); - when(suggestionRepository.findById("s1")).thenReturn(Optional.of(suggestion)); + when(suggestionRepository.findByIdForUpdate("s1")).thenReturn(Optional.of(suggestion)); assertThatThrownBy(() -> applier.approve("s1", "admin", null)) .isInstanceOf(IllegalArgumentException.class) @@ -108,7 +247,7 @@ void approveSchemaDoc_blankTarget_throwsInsteadOfSilentSkip() { void approveRejectedSuggestion_isRejected() { CodeKnowledgeSuggestion suggestion = baseSuggestion(); suggestion.setStatus(CodeKnowledgeSuggestion.Status.REJECTED); - when(suggestionRepository.findById("s1")).thenReturn(Optional.of(suggestion)); + when(suggestionRepository.findByIdForUpdate("s1")).thenReturn(Optional.of(suggestion)); assertThatThrownBy(() -> applier.approve("s1", "admin", null)) .isInstanceOf(IllegalStateException.class) diff --git a/scripts/self-host/e2e-review-approvals.py b/scripts/self-host/e2e-review-approvals.py index 1a792ed..23ed215 100755 --- a/scripts/self-host/e2e-review-approvals.py +++ b/scripts/self-host/e2e-review-approvals.py @@ -9,13 +9,16 @@ import json import os -import subprocess import sys import urllib.error from http.cookiejar import CookieJar from pathlib import Path +from concurrent.futures import ThreadPoolExecutor from urllib.request import HTTPCookieProcessor, Request, build_opener +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import vaultdb # noqa: E402 + ROOT = Path(__file__).resolve().parents[2] ENV = ROOT / ".env" @@ -93,14 +96,10 @@ def check(name: str, ok: bool, detail: str = ""): check("has KNOWLEDGE_ENTRY pending", len(knowledge) >= 1, f"n={len(knowledge)}") # ── 2. Constraint bootstrap: CODE_DERIVED must be allowed ────────────── - constraint = subprocess.check_output( - [ - "sudo", "-u", "postgres", "psql", "-d", "dba_agent", "-At", "-c", - "SELECT pg_get_constraintdef(oid) FROM pg_constraint " - "WHERE conname='schema_documentation_source_check'", - ], - text=True, - ).strip() + constraint = vaultdb.query( + "SELECT pg_get_constraintdef(oid) FROM pg_constraint " + "WHERE conname='schema_documentation_source_check'" + ) check("CODE_DERIVED in source CHECK", "CODE_DERIVED" in constraint, constraint) # ── 3. Approve SCHEMA_DOC (the customer failure path) ────────────────── @@ -195,22 +194,25 @@ def check(name: str, ok: bool, detail: str = ""): check("knowledge approvals applied", len(knowledge_ok) >= 1, f"n={len(knowledge_ok)}") # ── 10. Simulate missing CODE_DERIVED then ensure repair path works ──── - # Existing CODE_DERIVED rows block shrinking the CHECK, so park them first. - subprocess.check_call([ - "sudo", "-u", "postgres", "psql", "-d", "dba_agent", "-c", - "UPDATE schema_documentation SET source='USER' WHERE source='CODE_DERIVED'; " + # Existing CODE_DERIVED rows block shrinking the CHECK, so park them first — + # in a scratch table, not by rewriting source in place. The original version + # flipped every real CODE_DERIVED row to USER and never restored it, so a run + # against a live install silently relabelled the user's approved code-derived + # docs; it would also collide with V116's unique index the moment a user-written + # doc existed for the same column. + vaultdb.execute( + "DROP TABLE IF EXISTS e2e_parked_code_derived; " + "CREATE TABLE e2e_parked_code_derived AS " + " SELECT * FROM schema_documentation WHERE source='CODE_DERIVED'; " + "DELETE FROM schema_documentation WHERE source='CODE_DERIVED'; " "ALTER TABLE schema_documentation DROP CONSTRAINT IF EXISTS schema_documentation_source_check; " "ALTER TABLE schema_documentation ADD CONSTRAINT schema_documentation_source_check " "CHECK (source::text = ANY (ARRAY['USER','AI_GENERATED','CSV_IMPORT']::text[]));", - ]) - broken = subprocess.check_output( - [ - "sudo", "-u", "postgres", "psql", "-d", "dba_agent", "-At", "-c", - "SELECT pg_get_constraintdef(oid) FROM pg_constraint " - "WHERE conname='schema_documentation_source_check'", - ], - text=True, - ).strip() + ) + broken = vaultdb.query( + "SELECT pg_get_constraintdef(oid) FROM pg_constraint " + "WHERE conname='schema_documentation_source_check'" + ) check("can break CHECK for simulation", "CODE_DERIVED" not in broken) page6 = req(f"{backend}/code-scan/suggestions?connectionId={conn}&status=PENDING&page=0&size=20") @@ -233,21 +235,37 @@ def check(name: str, ok: bool, detail: str = ""): else: check("broken CHECK yields failed bulk with error detail", False, "no pending SCHEMA_DOC") - # Repair CHECK the same way the startup initializer does (without restart). - subprocess.check_call([ - "sudo", "-u", "postgres", "psql", "-d", "dba_agent", "-c", + # Repair CHECK the same way the startup initializer does (without restart), + # then un-park every row the simulation removed. The NOT EXISTS guard skips a + # row the post-repair retry has meanwhile recreated under the same key, which + # the unique index would otherwise reject. + vaultdb.execute( "ALTER TABLE schema_documentation DROP CONSTRAINT IF EXISTS schema_documentation_source_check; " "ALTER TABLE schema_documentation ADD CONSTRAINT schema_documentation_source_check " "CHECK (source::text = ANY (ARRAY['USER','AI_GENERATED','CSV_IMPORT','CODE_DERIVED']::text[]));", - ]) - repaired = subprocess.check_output( - [ - "sudo", "-u", "postgres", "psql", "-d", "dba_agent", "-At", "-c", - "SELECT pg_get_constraintdef(oid) FROM pg_constraint " - "WHERE conname='schema_documentation_source_check'", - ], - text=True, - ).strip() + ) + parked_count = vaultdb.query("SELECT count(*) FROM e2e_parked_code_derived") + vaultdb.execute( + "INSERT INTO schema_documentation SELECT p.* FROM e2e_parked_code_derived p " + "WHERE NOT EXISTS (SELECT 1 FROM schema_documentation d WHERE d.id = p.id) " + " AND NOT EXISTS (SELECT 1 FROM schema_documentation d " + " WHERE d.connection_id = p.connection_id " + " AND d.object_type = p.object_type " + " AND d.object_name = p.object_name " + " AND coalesce(d.parent_object,'') = coalesce(p.parent_object,'') " + " AND d.source = p.source); " + "DROP TABLE e2e_parked_code_derived;", + ) + restored = vaultdb.query("SELECT count(*) FROM schema_documentation WHERE source='CODE_DERIVED'") + check( + "parked CODE_DERIVED rows restored", + int(restored) >= int(parked_count), + f"parked={parked_count} now={restored}", + ) + repaired = vaultdb.query( + "SELECT pg_get_constraintdef(oid) FROM pg_constraint " + "WHERE conname='schema_documentation_source_check'" + ) check("CHECK repaired with CODE_DERIVED", "CODE_DERIVED" in repaired) if schema_pending: @@ -261,6 +279,169 @@ def check(name: str, ok: bool, detail: str = ""): f"status={retry.get('status')}", ) + # ── 11. Duplicate schema_documentation rows must not wedge approve ───── + # The customer's "APPROVED 0 OF 2" was two IncorrectResultSizeDataAccessException + # ("Query did not return a unique result: 2 results were returned") thrown by the + # upsert lookup in CodeSuggestionApplier.applySchemaDoc against duplicate rows a + # double-submitted bulk approve had left behind. + # + # Approve once to learn the exact key the applier writes (it database-qualifies + # parent_object via resolveDatabaseName, so guessing it from existing rows picks + # the wrong prefix and the test silently exercises nothing), then reset the + # suggestion, plant an older duplicate under that key, and approve again. + page7 = req(f"{backend}/code-scan/suggestions?connectionId={conn}&status=PENDING&page=0&size=50") + dup_target = next( + (s for s in (page7.get("content") or []) + if s.get("targetKind") == "SCHEMA_DOC" and "." in (s.get("targetObject") or "")), + None, + ) + if not dup_target: + check("duplicate rows collapse on approve", False, "no pending column SCHEMA_DOC") + else: + first = req( + f"{backend}/code-scan/suggestions/{dup_target['id']}/decide?connectionId={conn}", + {"decision": "APPROVED"}, + ) + doc_id = first.get("appliedDocId") + check("duplicate-test baseline approve", bool(doc_id), json.dumps(first)[:120]) + + key = vaultdb.query( + "SELECT object_name || '|' || coalesce(parent_object,'') " + f"FROM schema_documentation WHERE id='{doc_id}'" + ) + column, _, qualified = key.partition("|") + # Anchor the planted row's timestamp to the real one: approve UPDATES the + # row an earlier scan wrote, so its created_at is historical, and a + # hardcoded "old" date can easily be the newer of the two. + baseline_created = vaultdb.query( + f"SELECT created_at FROM schema_documentation WHERE id='{doc_id}'" + ) + where = ( + f"connection_id='{conn}' AND object_type='COLUMN' AND object_name='{column}' " + f"AND coalesce(parent_object,'')='{qualified}' AND source='CODE_DERIVED'" + ) + + # The unique index is what stops this happening for real, so drop it for the + # simulation. restore_unique_index() runs even if an assertion below throws — + # leaving the install without the constraint would be worse than a failed test. + vaultdb.execute("DROP INDEX IF EXISTS ux_schema_doc_target") + try: + vaultdb.execute( + "INSERT INTO schema_documentation " + "(id, connection_id, object_type, object_name, parent_object, description, " + " source, created_at) " + f"VALUES ('e2e-dup-older', '{conn}', 'COLUMN', '{column}', '{qualified}', " + f"'stale duplicate', 'CODE_DERIVED', TIMESTAMP '{baseline_created}' - INTERVAL '1 hour')" + ) + vaultdb.execute( + "UPDATE code_knowledge_suggestion SET status='PENDING', applied_doc_id=NULL, " + f"decided_at=NULL, decided_by=NULL WHERE id='{dup_target['id']}'" + ) + # A second, already-approved suggestion pointing at the row that is about + # to be deleted — its reference must follow the survivor, not dangle. + vaultdb.execute( + "UPDATE code_knowledge_suggestion SET applied_doc_id='e2e-dup-older' " + f"WHERE id = (SELECT id FROM code_knowledge_suggestion WHERE connection_id='{conn}' " + f" AND status='APPROVED' AND id <> '{dup_target['id']}' LIMIT 1)" + ) + before = vaultdb.query(f"SELECT count(*) FROM schema_documentation WHERE {where}") + check("duplicate pair seeded", before == "2", f"rows={before}") + + dup_bulk = req( + f"{backend}/code-scan/suggestions/bulk-decide?connectionId={conn}", + {"ids": [dup_target["id"]], "decision": "APPROVED"}, + ) + check( + "duplicate rows collapse on approve", + dup_bulk.get("succeeded") == 1 and dup_bulk.get("failed") == 0, + json.dumps(dup_bulk), + ) + after = vaultdb.query(f"SELECT count(*) FROM schema_documentation WHERE {where}") + check("collapsed to a single row", after == "1", f"rows={after}") + survivor = vaultdb.query(f"SELECT id FROM schema_documentation WHERE {where}") + # Newest wins — the row applied_doc_id already pointed at. + check("newest duplicate survived", survivor == doc_id, f"{survivor} vs {doc_id}") + content = vaultdb.query( + f"SELECT description FROM schema_documentation WHERE id='{survivor}'" + ) + check("survivor holds approved content", "stale duplicate" not in content, content[:60]) + gone = vaultdb.query( + "SELECT count(*) FROM schema_documentation WHERE id='e2e-dup-older'" + ) + check("older duplicate deleted", gone == "0", gone) + embedding = vaultdb.query("SELECT count(*) FROM rag_documents WHERE id='e2e-dup-older'") + check("loser embedding removed", embedding == "0", embedding) + dangling = vaultdb.query( + "SELECT count(*) FROM code_knowledge_suggestion WHERE applied_doc_id='e2e-dup-older'" + ) + check("applied_doc_id repointed off the deleted row", dangling == "0", dangling) + orphans = vaultdb.query( + "SELECT count(*) FROM code_knowledge_suggestion s WHERE s.applied_doc_id IS NOT NULL " + "AND NOT EXISTS (SELECT 1 FROM schema_documentation d WHERE d.id = s.applied_doc_id)" + ) + check("no dangling applied_doc_id anywhere", orphans == "0", orphans) + finally: + # Only ever remove the planted row, and only while nothing points at it. + # An earlier version deleted it unconditionally, which destroyed real + # approved content on the run where collapse had chosen it as survivor. + vaultdb.execute( + "DELETE FROM schema_documentation d WHERE d.id='e2e-dup-older' " + "AND NOT EXISTS (SELECT 1 FROM code_knowledge_suggestion s " + " WHERE s.applied_doc_id = d.id)" + ) + vaultdb.execute( + "CREATE UNIQUE INDEX IF NOT EXISTS ux_schema_doc_target ON schema_documentation " + "(connection_id, object_type, object_name, coalesce(parent_object, ''), source)" + ) + + # ── 12. The unique index refuses a second row for the same key ───────── + idx = vaultdb.query( + "SELECT count(*) FROM pg_indexes WHERE indexname='ux_schema_doc_target'" + ) + check("unique index present", idx == "1", idx) + try: + vaultdb.execute( + "INSERT INTO schema_documentation " + "(id, connection_id, object_type, object_name, parent_object, description, source, created_at) " + "SELECT 'e2e-dup-clash', connection_id, object_type, object_name, parent_object, " + " description, source, now() " + "FROM schema_documentation LIMIT 1" + ) + vaultdb.execute("DELETE FROM schema_documentation WHERE id='e2e-dup-clash'") + check("unique index rejects a duplicate key", False, "insert succeeded") + except Exception as e: # noqa: BLE001 - psql exiting non-zero IS the assertion + check("unique index rejects a duplicate key", True, type(e).__name__) + + # ── 13. Concurrent approve of one suggestion writes exactly one doc row ─ + # Two bulk approves racing on the same PENDING row is what created the + # duplicates in the first place; approve now loads the suggestion FOR UPDATE. + page8 = req(f"{backend}/code-scan/suggestions?connectionId={conn}&status=PENDING&page=0&size=50") + race_target = next( + (s for s in (page8.get("content") or []) + if s.get("targetKind") == "SCHEMA_DOC" and "." in (s.get("targetObject") or "")), + None, + ) + if not race_target: + check("concurrent approve writes one row", False, "no pending column SCHEMA_DOC") + else: + url = f"{backend}/code-scan/suggestions/bulk-decide?connectionId={conn}" + payload = {"ids": [race_target["id"]], "decision": "APPROVED"} + with ThreadPoolExecutor(max_workers=2) as pool: + results = [f.result() for f in + [pool.submit(req, url, payload), pool.submit(req, url, payload)]] + parent, _, column = race_target["targetObject"].partition(".") + rows = vaultdb.query( + f"SELECT count(*) FROM schema_documentation WHERE connection_id='{conn}' " + f"AND object_type='COLUMN' AND object_name='{column}' " + f"AND parent_object LIKE '%{parent}' AND source='CODE_DERIVED'" + ) + check("concurrent approve writes one row", rows == "1", f"rows={rows}") + check( + "neither concurrent approve reported an error", + all(r.get("failed") == 0 for r in results), + json.dumps(results), + ) + print() if failures: print(f"✗ {len(failures)} failure(s): {failures}", file=sys.stderr) diff --git a/scripts/self-host/seed-review-suggestions.py b/scripts/self-host/seed-review-suggestions.py index dd302c5..f062190 100755 --- a/scripts/self-host/seed-review-suggestions.py +++ b/scripts/self-host/seed-review-suggestions.py @@ -12,6 +12,8 @@ import argparse import json import os +import shutil +import subprocess import sys import uuid from datetime import datetime, timezone @@ -37,6 +39,45 @@ def load_env(path: Path) -> dict[str, str]: return out +def apply_sql(sql_path: Path) -> int: + """Run the seed SQL against the vault DB, host install or Compose. + + A Compose deployment has no host-side postgres role (or psql at all), so the + original `sudo -u postgres psql` path only ever worked on a bare-metal + install — the verify command in the PR that added this script failed on the + topology `scripts/self-host/install.sh` actually produces. + """ + redirect = ">/tmp/seed-review.out 2>&1" + if shutil.which("psql"): + rc = os.system( + f"sudo -u postgres psql -d dba_agent -v ON_ERROR_STOP=1 -f {sql_path} {redirect}" + ) + if rc == 0: + return 0 + print("→ host psql failed; trying the Compose postgres container", file=sys.stderr) + + container = compose_postgres_container() + if not container: + print("No psql on PATH and no running Compose postgres container found.", file=sys.stderr) + return 1 + print(f"→ applying via docker exec {container}") + return os.system( + f"docker exec -i {container} psql -U postgres -d dba_agent -v ON_ERROR_STOP=1 " + f"< {sql_path} {redirect}" + ) + + +def compose_postgres_container() -> str | None: + """Name of the running postgres container, whatever the compose project is.""" + out = subprocess.run( + ["docker", "ps", "--filter", "label=com.docker.compose.service=postgres", + "--format", "{{.Names}}"], + capture_output=True, text=True, check=False, + ) + names = [n for n in out.stdout.split() if n] + return names[0] if names else None + + def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("connection_id", nargs="?") @@ -155,7 +196,7 @@ def req(url: str, data=None, method: str | None = None): sql_path = Path("/tmp/seed-review-suggestions.sql") sql_path.write_text(sql) print(f"→ writing {sql_path} ({args.count} suggestions)") - rc = os.system(f"sudo -u postgres psql -d dba_agent -v ON_ERROR_STOP=1 -f {sql_path} >/tmp/seed-review.out 2>&1") + rc = apply_sql(sql_path) if rc != 0: print(open("/tmp/seed-review.out").read(), file=sys.stderr) return 1 diff --git a/scripts/self-host/vaultdb.py b/scripts/self-host/vaultdb.py new file mode 100644 index 0000000..8201374 --- /dev/null +++ b/scripts/self-host/vaultdb.py @@ -0,0 +1,55 @@ +"""Run SQL against the DeepSQL vault DB from a self-host script. + +`sudo -u postgres psql` only works on a bare-metal install. `install.sh` produces +a Compose deployment where Postgres is a container and the host has no postgres +role — often no psql at all — so scripts that hardcode that command fail on the +topology they are meant to verify. Resolve the path once, here. +""" +from __future__ import annotations + +import shutil +import subprocess + + +def _host_psql(args: list[str]) -> list[str]: + return ["sudo", "-u", "postgres", "psql", "-d", "dba_agent", *args] + + +def _container_psql(container: str, args: list[str]) -> list[str]: + return ["docker", "exec", "-i", container, "psql", "-U", "postgres", "-d", "dba_agent", *args] + + +def postgres_container() -> str | None: + """Name of the running Compose postgres container, whatever the project is.""" + out = subprocess.run( + ["docker", "ps", "--filter", "label=com.docker.compose.service=postgres", + "--format", "{{.Names}}"], + capture_output=True, text=True, check=False, + ) + names = [n for n in out.stdout.split() if n] + return names[0] if names else None + + +def _command(args: list[str]) -> list[str]: + if shutil.which("psql"): + probe = subprocess.run(_host_psql(["-At", "-c", "SELECT 1"]), + capture_output=True, text=True, check=False) + if probe.returncode == 0: + return _host_psql(args) + container = postgres_container() + if not container: + raise RuntimeError( + "Cannot reach the vault DB: no usable host psql and no running " + "Compose postgres container." + ) + return _container_psql(container, args) + + +def query(sql: str) -> str: + """Run SQL and return stdout, raising on failure.""" + return subprocess.check_output(_command(["-At", "-c", sql]), text=True).strip() + + +def execute(sql: str) -> None: + """Run SQL for effect, raising on failure.""" + subprocess.check_call(_command(["-v", "ON_ERROR_STOP=1", "-c", sql])) diff --git a/src/lib/hooks/queries/useCodeScan.js b/src/lib/hooks/queries/useCodeScan.js index 18ffbf2..10e4ed8 100644 --- a/src/lib/hooks/queries/useCodeScan.js +++ b/src/lib/hooks/queries/useCodeScan.js @@ -149,15 +149,33 @@ export function useAllCodeScanSuggestions({ connectionId, status = 'PENDING' }) }) } +/** + * Everything an approve/reject writes, in one place. + * + * A decision is not confined to the suggestion row: approving a SCHEMA_DOC + * upserts `schema_documentation` (served by `brain/notes`, which backs the Write + * notes tab and its coverage counts) and re-embeds it, and approving a + * KNOWLEDGE_ENTRY creates a company knowledge entry. Invalidating only codeScan + + * companyKnowledge left every schema-doc-derived count stale until a page reload. + */ +function invalidateAfterDecision(queryClient, connectionId) { + if (!connectionId) return + ;[ + queryKeys.codeScan.all(connectionId), + queryKeys.companyKnowledge.all(connectionId), + // schema_documentation — brain/notes, coverage counts, understanding. + queryKeys.brain.all(connectionId), + // Accepting a description can resolve an ambiguity flagged in Unresolved. + queryKeys.schemaContext.all(connectionId), + ].forEach((queryKey) => queryClient.invalidateQueries({ queryKey })) +} + export function useDecideCodeScanSuggestion() { const queryClient = useQueryClient() return useMutation({ mutationFn: codeScanAPI.decide, onSuccess: (_data, variables) => { - if (variables?.connectionId) { - queryClient.invalidateQueries({ queryKey: queryKeys.codeScan.all(variables.connectionId) }) - queryClient.invalidateQueries({ queryKey: queryKeys.companyKnowledge.all(variables.connectionId) }) - } + invalidateAfterDecision(queryClient, variables?.connectionId) }, }) } @@ -167,10 +185,7 @@ export function useBulkDecideCodeScanSuggestions() { return useMutation({ mutationFn: codeScanAPI.bulkDecide, onSuccess: (_data, variables) => { - if (variables?.connectionId) { - queryClient.invalidateQueries({ queryKey: queryKeys.codeScan.all(variables.connectionId) }) - queryClient.invalidateQueries({ queryKey: queryKeys.companyKnowledge.all(variables.connectionId) }) - } + invalidateAfterDecision(queryClient, variables?.connectionId) }, }) }