diff --git a/AGENTS.md b/AGENTS.md index cff7764..13eb0f3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -191,6 +191,11 @@ only covers cloud-specific, non-obvious caveats. `sudo -u postgres psql -f docker/postgres/init/11_create_acme_erp.sql` then `bash scripts/seed-acme-erp.sh` (registers `ACME ERP (Multi-Schema)` when backend auth is disabled or you have an admin session cookie). +- **Company Knowledge → Review queue** (code-scan suggestions): seed without an LLM via + `python3 scripts/self-host/seed-review-suggestions.py --count 50`, then exercise approve/ + reject/bulk edge cases with `python3 scripts/self-host/e2e-review-approvals.py`. + Approving `SCHEMA_DOC` rows needs `CODE_DERIVED` on `schema_documentation_source_check` + (startup initializer repairs this; Hibernate `ddl-auto` does not). ### Non-obvious setup caveats (each cost real debugging time) diff --git a/backend/src/main/java/com/dbaagent/config/SchemaDocumentationSourceCompatibilityInitializer.java b/backend/src/main/java/com/dbaagent/config/SchemaDocumentationSourceCompatibilityInitializer.java new file mode 100644 index 0000000..2dfd635 --- /dev/null +++ b/backend/src/main/java/com/dbaagent/config/SchemaDocumentationSourceCompatibilityInitializer.java @@ -0,0 +1,74 @@ +package com.dbaagent.config; + +import com.dbaagent.model.DocumentationSource; +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 javax.sql.DataSource; +import java.util.Arrays; +import java.util.stream.Collectors; + +/** + * Keeps {@code schema_documentation.source} CHECK aligned with + * {@link DocumentationSource}. + * + *

Hibernate {@code ddl-auto=update} does not rewrite CHECK constraints when an + * enum gains a value. Self-host installs that predate V90 therefore reject + * {@code CODE_DERIVED} rows written by code-scan suggestion approve — the Review + * queue shows {@code APPROVED 0 OF N} while every SCHEMA_DOC decide is swallowed + * by bulk-decide. Mirrors {@link BrainInitSchemaCompatibilityInitializer}. + */ +@Configuration +@Slf4j +public class SchemaDocumentationSourceCompatibilityInitializer { + + private static final String TABLE = "schema_documentation"; + private static final String COLUMN = "source"; + private static final String CONSTRAINT = "schema_documentation_source_check"; + + @Bean("schemaDocumentationSourceCompatibilityBootstrap") + @DependsOn("entityManagerFactory") + public Object schemaDocumentationSourceCompatibilityBootstrap(DataSource dataSource) { + JdbcTemplate jdbc = new JdbcTemplate(dataSource); + if (!tableExists(jdbc, TABLE) || !columnExists(jdbc, TABLE, COLUMN)) { + return new Object(); + } + + String allowed = Arrays.stream(DocumentationSource.values()) + .map(DocumentationSource::name) + .map(v -> "'" + v + "'") + .collect(Collectors.joining(", ")); + + jdbc.execute("ALTER TABLE " + TABLE + " DROP CONSTRAINT IF EXISTS " + CONSTRAINT); + jdbc.execute( + "ALTER TABLE " + TABLE + + " ADD CONSTRAINT " + CONSTRAINT + + " CHECK ((" + COLUMN + ")::text = ANY (ARRAY[" + allowed + "]::text[]))" + ); + log.info("Ensured {} allows DocumentationSource values: {}", CONSTRAINT, allowed); + return new Object(); + } + + 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 columnExists(JdbcTemplate jdbc, String tableName, String columnName) { + Integer count = jdbc.queryForObject(""" + SELECT COUNT(*) + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = ? + AND column_name = ? + """, Integer.class, tableName, columnName); + return count != null && count > 0; + } +} diff --git a/backend/src/main/java/com/dbaagent/controller/CodeScanController.java b/backend/src/main/java/com/dbaagent/controller/CodeScanController.java index 93007fc..fb3198c 100644 --- a/backend/src/main/java/com/dbaagent/controller/CodeScanController.java +++ b/backend/src/main/java/com/dbaagent/controller/CodeScanController.java @@ -15,6 +15,7 @@ import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import java.io.IOException; +import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; @@ -156,16 +157,18 @@ public ResponseEntity> bulkDecide( if (body == null || body.ids() == null || body.ids().isEmpty()) { return ResponseEntity.badRequest().body(Map.of("error", "ids required")); } - var processed = codeScanService.bulkDecide( + var result = codeScanService.bulkDecide( body.ids(), body.decision(), accessControlService.getCurrentUsername(), body.note() ); - return ResponseEntity.ok(Map.of( - "requested", body.ids().size(), - "succeeded", processed.size() - )); + Map payload = new LinkedHashMap<>(); + payload.put("requested", body.ids().size()); + payload.put("succeeded", result.succeeded().size()); + payload.put("failed", result.failures().size()); + payload.put("failures", result.failures()); + return ResponseEntity.ok(payload); } private static CodeKnowledgeSuggestion.Status parseStatus(String s) { @@ -192,4 +195,9 @@ public ResponseEntity> handleIO(IOException e) { public ResponseEntity> handleBadInput(IllegalArgumentException e) { return ResponseEntity.badRequest().body(Map.of("error", e.getMessage())); } + + @ExceptionHandler(IllegalStateException.class) + public ResponseEntity> handleIllegalState(IllegalStateException e) { + return ResponseEntity.badRequest().body(Map.of("error", e.getMessage())); + } } 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 a0fe2d0..e9e0513 100644 --- a/backend/src/main/java/com/dbaagent/service/codescan/CodeScanService.java +++ b/backend/src/main/java/com/dbaagent/service/codescan/CodeScanService.java @@ -579,24 +579,46 @@ public CodeKnowledgeSuggestion decide(String suggestionId, * transaction inside applier.approve/reject, so one bad row cannot mark a * shared transaction rollback-only and break every subsequent item. */ - public List bulkDecide(List ids, - String decision, - String decidedBy, - String note) { + public BulkDecideResult bulkDecide(List ids, + String decision, + String decidedBy, + String note) { List out = new ArrayList<>(); - int failures = 0; + List> failures = new ArrayList<>(); for (String id : ids) { try { out.add(decide(id, decision, decidedBy, note)); } catch (Exception e) { - failures++; - log.warn("bulk decide skipped {}: {}", id, e.getMessage()); + String message = rootMessage(e); + failures.add(Map.of("id", id, "error", message)); + log.warn("bulk decide skipped {}: {}", id, message); } } - if (failures > 0) { - log.info("bulk decide: {} succeeded, {} failed", out.size(), failures); + if (!failures.isEmpty()) { + log.info("bulk decide: {} succeeded, {} failed", out.size(), failures.size()); } - return out; + return new BulkDecideResult(out, failures); + } + + public record BulkDecideResult( + List succeeded, + List> failures + ) {} + + private static String rootMessage(Throwable e) { + Throwable cur = e; + String best = e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage(); + while (cur != null) { + if (cur.getMessage() != null && !cur.getMessage().isBlank()) { + best = cur.getMessage(); + } + cur = cur.getCause(); + } + // Keep API payloads short — full stack stays in logs. + if (best.length() > 400) { + return best.substring(0, 397) + "..."; + } + return best; } // ---- Helpers ---- 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 77f7837..174bf64 100644 --- a/backend/src/main/java/com/dbaagent/service/codescan/CodeSuggestionApplier.java +++ b/backend/src/main/java/com/dbaagent/service/codescan/CodeSuggestionApplier.java @@ -82,15 +82,20 @@ private void applySchemaDoc(CodeKnowledgeSuggestion suggestion, String decidedBy String objectName; String parentObject = null; String target = suggestion.getTargetObject(); + // Never silently skip — bulk-decide would report succeeded while nothing + // was written, and the Review UI would look like a no-op approval. if (target == null || target.isBlank()) { - log.warn("SCHEMA_DOC suggestion {} has no targetObject; skipping", suggestion.getId()); - return; + throw new IllegalArgumentException( + "SCHEMA_DOC suggestion " + suggestion.getId() + " has no targetObject" + ); } if (objectType == SchemaDocumentation.DocumentationType.COLUMN) { int dot = target.indexOf('.'); if (dot <= 0 || dot >= target.length() - 1) { - log.warn("SCHEMA_DOC column suggestion {} has malformed target '{}'", suggestion.getId(), target); - return; + throw new IllegalArgumentException( + "SCHEMA_DOC column suggestion " + suggestion.getId() + + " has malformed target '" + target + "'" + ); } parentObject = target.substring(0, dot); objectName = target.substring(dot + 1); diff --git a/backend/src/test/java/com/dbaagent/service/codescan/CodeSuggestionApplierTest.java b/backend/src/test/java/com/dbaagent/service/codescan/CodeSuggestionApplierTest.java new file mode 100644 index 0000000..773806f --- /dev/null +++ b/backend/src/test/java/com/dbaagent/service/codescan/CodeSuggestionApplierTest.java @@ -0,0 +1,130 @@ +package com.dbaagent.service.codescan; + +import com.dbaagent.model.DocumentationSource; +import com.dbaagent.model.SchemaDocumentation; +import com.dbaagent.model.SchemaMetadata; +import com.dbaagent.model.code.CodeKnowledgeSuggestion; +import com.dbaagent.repository.CodeKnowledgeSuggestionRepository; +import com.dbaagent.repository.SchemaDocumentationRepository; +import com.dbaagent.service.CompanyKnowledgeService; +import com.dbaagent.service.SchemaScannerService; +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.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +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.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class CodeSuggestionApplierTest { + + @Mock private CodeKnowledgeSuggestionRepository suggestionRepository; + @Mock private SchemaDocumentationRepository schemaDocRepository; + @Mock private CompanyKnowledgeService companyKnowledgeService; + @Mock private TrainingService trainingService; + @Mock private SchemaScannerService schemaScannerService; + + private CodeSuggestionApplier applier; + + @BeforeEach + void setUp() { + applier = new CodeSuggestionApplier( + suggestionRepository, + schemaDocRepository, + companyKnowledgeService, + trainingService, + schemaScannerService + ); + } + + @Test + void approveSchemaDocColumn_writesCodeDerivedRow() throws Exception { + CodeKnowledgeSuggestion suggestion = baseSuggestion(); + suggestion.setTargetKind(CodeKnowledgeSuggestion.TargetKind.SCHEMA_DOC); + 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.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(Optional.empty()); + SchemaMetadata schema = new SchemaMetadata(); + schema.setDatabaseName("acme_erp"); + when(schemaScannerService.scanSchema("conn-1")).thenReturn(schema); + when(schemaDocRepository.save(any())).thenAnswer(inv -> { + SchemaDocumentation doc = inv.getArgument(0); + doc.setId("doc-1"); + return doc; + }); + + CodeKnowledgeSuggestion approved = applier.approve("s1", "admin", null); + + assertThat(approved.getStatus()).isEqualTo(CodeKnowledgeSuggestion.Status.APPROVED); + assertThat(approved.getAppliedDocId()).isEqualTo("doc-1"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(SchemaDocumentation.class); + verify(schemaDocRepository).save(captor.capture()); + SchemaDocumentation saved = captor.getValue(); + assertThat(saved.getSource()).isEqualTo(DocumentationSource.CODE_DERIVED); + assertThat(saved.getObjectName()).isEqualTo("party_size"); + assertThat(saved.getParentObject()).isEqualTo("acme_erp.fct_ashram_visit"); + assertThat(saved.getBusinessTerms()).isEqualTo("party size"); + verify(trainingService).upsertDocumentationEmbedding(saved); + } + + @Test + void approveSchemaDoc_blankTarget_throwsInsteadOfSilentSkip() { + CodeKnowledgeSuggestion suggestion = baseSuggestion(); + suggestion.setTargetKind(CodeKnowledgeSuggestion.TargetKind.SCHEMA_DOC); + suggestion.setTargetObject(" "); + when(suggestionRepository.findById("s1")).thenReturn(Optional.of(suggestion)); + + assertThatThrownBy(() -> applier.approve("s1", "admin", null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("no targetObject"); + verify(schemaDocRepository, never()).save(any()); + verify(suggestionRepository, never()).save(any()); + } + + @Test + void approveRejectedSuggestion_isRejected() { + CodeKnowledgeSuggestion suggestion = baseSuggestion(); + suggestion.setStatus(CodeKnowledgeSuggestion.Status.REJECTED); + when(suggestionRepository.findById("s1")).thenReturn(Optional.of(suggestion)); + + assertThatThrownBy(() -> applier.approve("s1", "admin", null)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("cannot be approved"); + } + + private static CodeKnowledgeSuggestion baseSuggestion() { + return CodeKnowledgeSuggestion.builder() + .id("s1") + .jobId("job-1") + .connectionId("conn-1") + .title("Visit party size") + .content("Number of people in the visiting party.") + .confidence(0.99) + .status(CodeKnowledgeSuggestion.Status.PENDING) + .sourceFiles(List.of(Map.of("path", "src/Seed.java", "startLine", 1, "endLine", 10))) + .build(); + } +} diff --git a/scripts/self-host/e2e-review-approvals.py b/scripts/self-host/e2e-review-approvals.py new file mode 100755 index 0000000..1a792ed --- /dev/null +++ b/scripts/self-host/e2e-review-approvals.py @@ -0,0 +1,273 @@ +#!/usr/bin/env python3 +"""E2E edge-case suite for Company Knowledge Review approvals. + +Requires a running stack + seeded PENDING suggestions: + python3 scripts/self-host/seed-review-suggestions.py --count 20 + python3 scripts/self-host/e2e-review-approvals.py [connectionId] +""" +from __future__ import annotations + +import json +import os +import subprocess +import sys +import urllib.error +from http.cookiejar import CookieJar +from pathlib import Path +from urllib.request import HTTPCookieProcessor, Request, build_opener + +ROOT = Path(__file__).resolve().parents[2] +ENV = ROOT / ".env" + + +def load_env(path: Path) -> dict[str, str]: + out: dict[str, str] = {} + if not path.exists(): + return out + for line in path.read_text().splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + k, v = line.split("=", 1) + out[k.strip()] = v.strip().strip('"').strip("'") + return out + + +def main() -> int: + env = {**load_env(ENV), **os.environ} + email = env.get("DEEPSQL_INITIAL_ADMIN_EMAIL") or env.get("DEEPSQL_SMOKE_EMAIL") + password = env.get("DEEPSQL_INITIAL_ADMIN_PASSWORD") or env.get("DEEPSQL_SMOKE_PASSWORD") + if not email or not password: + print("Missing admin credentials", file=sys.stderr) + return 1 + + backend = f"http://localhost:{env.get('DEEPSQL_BACKEND_PORT', '8080')}/api" + opener = build_opener(HTTPCookieProcessor(CookieJar())) + failures: list[str] = [] + + def req(url: str, data=None, method: str | None = None): + body = None + headers: dict[str, str] = {} + if data is not None: + body = json.dumps(data).encode() + headers["Content-Type"] = "application/json" + m = method or ("POST" if data is not None else "GET") + r = Request(url, data=body, headers=headers, method=m) + with opener.open(r, timeout=180) as resp: + return json.loads(resp.read().decode() or "null") + + def check(name: str, ok: bool, detail: str = ""): + status = "PASS" if ok else "FAIL" + print(f"[{status}] {name}{(': ' + detail) if detail else ''}") + if not ok: + failures.append(name) + + print("→ login") + req(f"{backend}/auth/login", {"email": email, "password": password}) + + conn = sys.argv[1] if len(sys.argv) > 1 else None + if not conn: + conns = req(f"{backend}/connections") + items = conns if isinstance(conns, list) else (conns.get("connections") or []) + conn = (items[0].get("connectionId") or items[0].get("id")) if items else None + if not conn: + print("No connection", file=sys.stderr) + return 1 + print(f"→ connection {conn}") + + # ── 1. Count consistency: badge probe vs full page ───────────────────── + probe = req(f"{backend}/code-scan/suggestions?connectionId={conn}&status=PENDING&page=0&size=1") + page = req(f"{backend}/code-scan/suggestions?connectionId={conn}&status=PENDING&page=0&size=200") + total = probe.get("totalElements") + content_len = len(page.get("content") or []) + check( + "pending count consistency", + total == content_len or (total > 200 and content_len == 200), + f"totalElements={total} content={content_len}", + ) + + pending = [s for s in (page.get("content") or []) if s.get("status") == "PENDING"] + schema_docs = [s for s in pending if s.get("targetKind") == "SCHEMA_DOC"] + knowledge = [s for s in pending if s.get("targetKind") == "KNOWLEDGE_ENTRY"] + check("has SCHEMA_DOC pending", len(schema_docs) >= 1, f"n={len(schema_docs)}") + 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() + check("CODE_DERIVED in source CHECK", "CODE_DERIVED" in constraint, constraint) + + # ── 3. Approve SCHEMA_DOC (the customer failure path) ────────────────── + target = schema_docs[0] + approved = req( + f"{backend}/code-scan/suggestions/{target['id']}/decide?connectionId={conn}", + {"decision": "APPROVED"}, + ) + check( + "approve SCHEMA_DOC", + approved.get("status") == "APPROVED" and bool(approved.get("appliedDocId")), + f"status={approved.get('status')} appliedDocId={approved.get('appliedDocId')}", + ) + + # ── 4. Idempotent re-approve ─────────────────────────────────────────── + again = req( + f"{backend}/code-scan/suggestions/{target['id']}/decide?connectionId={conn}", + {"decision": "APPROVED"}, + ) + check("re-approve is idempotent", again.get("status") == "APPROVED") + + # ── 5. Reject cannot follow approve ──────────────────────────────────── + try: + req( + f"{backend}/code-scan/suggestions/{target['id']}/decide?connectionId={conn}", + {"decision": "REJECTED"}, + ) + check("reject after approve blocked", False, "expected 400") + except urllib.error.HTTPError as e: + check("reject after approve blocked", e.code == 400, f"http={e.code}") + + # ── 6. Reject a fresh pending item ───────────────────────────────────── + page2 = req(f"{backend}/code-scan/suggestions?connectionId={conn}&status=PENDING&page=0&size=50") + pending2 = page2.get("content") or [] + to_reject = next((s for s in pending2 if s.get("targetKind") == "SCHEMA_DOC"), None) + if to_reject: + rejected = req( + f"{backend}/code-scan/suggestions/{to_reject['id']}/decide?connectionId={conn}", + {"decision": "REJECTED", "note": "not useful"}, + ) + check("reject SCHEMA_DOC", rejected.get("status") == "REJECTED") + else: + check("reject SCHEMA_DOC", False, "no pending SCHEMA_DOC left") + + # ── 7. Bulk approve mix (schema + knowledge) reports per-id failures ─── + page3 = req(f"{backend}/code-scan/suggestions?connectionId={conn}&status=PENDING&page=0&size=50") + mix = [] + for kind in ("SCHEMA_DOC", "KNOWLEDGE_ENTRY"): + hit = next((s for s in (page3.get("content") or []) if s.get("targetKind") == kind), None) + if hit: + mix.append(hit["id"]) + if len(mix) >= 2: + bulk = req( + f"{backend}/code-scan/suggestions/bulk-decide?connectionId={conn}", + {"ids": mix, "decision": "APPROVED"}, + ) + check( + "bulk approve reports counts", + bulk.get("requested") == len(mix) + and bulk.get("succeeded") == len(mix) + and bulk.get("failed") == 0 + and isinstance(bulk.get("failures"), list), + json.dumps(bulk), + ) + else: + check("bulk approve reports counts", False, f"need 2 pending kinds, got {len(mix)}") + + # ── 8. Bulk with unknown id surfaces failure entry ───────────────────── + page4 = req(f"{backend}/code-scan/suggestions?connectionId={conn}&status=PENDING&page=0&size=1") + one = (page4.get("content") or [None])[0] + if one: + bulk_bad = req( + f"{backend}/code-scan/suggestions/bulk-decide?connectionId={conn}", + {"ids": [one["id"], "00000000-0000-0000-0000-000000000000"], "decision": "APPROVED"}, + ) + check( + "bulk partial failure surfaces failures[]", + bulk_bad.get("succeeded") == 1 + and bulk_bad.get("failed") == 1 + and len(bulk_bad.get("failures") or []) == 1, + json.dumps(bulk_bad), + ) + else: + check("bulk partial failure surfaces failures[]", False, "no pending left") + + # ── 9. Knowledge entry approve lands in company knowledge ────────────── + page5 = req(f"{backend}/code-scan/suggestions?connectionId={conn}&status=APPROVED&page=0&size=50") + knowledge_ok = [ + s for s in (page5.get("content") or []) + if s.get("targetKind") == "KNOWLEDGE_ENTRY" and s.get("appliedEntryId") + ] + 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'; " + "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() + 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") + schema_pending = next( + (s for s in (page6.get("content") or []) if s.get("targetKind") == "SCHEMA_DOC"), + None, + ) + if schema_pending: + bulk_broken = req( + f"{backend}/code-scan/suggestions/bulk-decide?connectionId={conn}", + {"ids": [schema_pending["id"]], "decision": "APPROVED"}, + ) + check( + "broken CHECK yields failed bulk with error detail", + bulk_broken.get("succeeded") == 0 + and bulk_broken.get("failed") == 1 + and "schema_documentation_source_check" in json.dumps(bulk_broken.get("failures")), + json.dumps(bulk_broken), + ) + 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", + "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() + check("CHECK repaired with CODE_DERIVED", "CODE_DERIVED" in repaired) + + if schema_pending: + retry = req( + f"{backend}/code-scan/suggestions/{schema_pending['id']}/decide?connectionId={conn}", + {"decision": "APPROVED"}, + ) + check( + "approve SCHEMA_DOC after CHECK repair", + retry.get("status") == "APPROVED" and bool(retry.get("appliedDocId")), + f"status={retry.get('status')}", + ) + + print() + if failures: + print(f"✗ {len(failures)} failure(s): {failures}", file=sys.stderr) + return 1 + print("✓ All review-approval edge cases passed") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/self-host/seed-review-suggestions.py b/scripts/self-host/seed-review-suggestions.py new file mode 100755 index 0000000..dd302c5 --- /dev/null +++ b/scripts/self-host/seed-review-suggestions.py @@ -0,0 +1,172 @@ +#!/usr/bin/env python3 +"""Seed code-scan review suggestions for E2E testing (no LLM required). + +Creates a code_scan_source + completed job + N PENDING suggestions +(SCHEMA_DOC + KNOWLEDGE_ENTRY) against an existing DeepSQL connection. + +Usage (repo root, backend running with auth): + python3 scripts/self-host/seed-review-suggestions.py [connectionId] [--count 50] +""" +from __future__ import annotations + +import argparse +import json +import os +import sys +import uuid +from datetime import datetime, timezone +from http.cookiejar import CookieJar +from pathlib import Path +from urllib.request import HTTPCookieProcessor, Request, build_opener +import urllib.error + +ROOT = Path(__file__).resolve().parents[2] +ENV = ROOT / ".env" + + +def load_env(path: Path) -> dict[str, str]: + out: dict[str, str] = {} + if not path.exists(): + return out + for line in path.read_text().splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + k, v = line.split("=", 1) + out[k.strip()] = v.strip().strip('"').strip("'") + return out + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("connection_id", nargs="?") + parser.add_argument("--count", type=int, default=50) + parser.add_argument("--name", default="E2E seeded app code") + args = parser.parse_args() + + env = {**load_env(ENV), **os.environ} + email = env.get("DEEPSQL_INITIAL_ADMIN_EMAIL") or env.get("DEEPSQL_SMOKE_EMAIL") + password = env.get("DEEPSQL_INITIAL_ADMIN_PASSWORD") or env.get("DEEPSQL_SMOKE_PASSWORD") + if not email or not password: + print("Missing admin credentials in .env", file=sys.stderr) + return 1 + + frontend = f"http://localhost:{env.get('DEEPSQL_FRONTEND_PORT', '3000')}" + backend = f"http://localhost:{env.get('DEEPSQL_BACKEND_PORT', '8080')}/api" + opener = build_opener(HTTPCookieProcessor(CookieJar())) + + def req(url: str, data=None, method: str | None = None): + body = None + headers: dict[str, str] = {} + if data is not None: + body = json.dumps(data).encode() + headers["Content-Type"] = "application/json" + m = method or ("POST" if data is not None else "GET") + r = Request(url, data=body, headers=headers, method=m) + with opener.open(r, timeout=60) as resp: + raw = resp.read().decode() or "null" + return json.loads(raw) + + print("→ login") + try: + req(f"{frontend}/api/auth/login", {"email": email, "password": password}) + except Exception: + req(f"{backend}/auth/login", {"email": email, "password": password}) + + conn_id = args.connection_id + if not conn_id: + conns = req(f"{backend}/connections") + items = conns if isinstance(conns, list) else (conns.get("connections") or []) + for c in items: + name = (c.get("connectionName") or "").lower() + if "acme" in name or "multi" in name: + conn_id = c.get("connectionId") or c.get("id") + break + if not conn_id and items: + conn_id = items[0].get("connectionId") or items[0].get("id") + if not conn_id: + print("FAIL: no connectionId", file=sys.stderr) + return 1 + print(f"→ connection {conn_id}") + + # Prefer SQL seed via vault DB for speed/reliability (no archive upload). + # Falls back to printing SQL if psql unavailable. + source_id = str(uuid.uuid4()) + job_id = str(uuid.uuid4()) + now = datetime.now(timezone.utc).replace(tzinfo=None).isoformat(sep=" ", timespec="seconds") + + suggestions_sql = [] + # Mix of SCHEMA_DOC (columns + tables) and KNOWLEDGE_ENTRY to cover both apply paths. + fixtures = [ + ("SCHEMA_DOC", "rpt_campaign_performance.refund_rate_pct", "Refund rate percentage", + "Percentage of refunded revenue for the campaign.", {"objectKind": "COLUMN", "businessTerms": ["refund rate"]}), + ("SCHEMA_DOC", "fct_ashram_visit.party_size", "Visit party size", + "Number of people in the visiting party.", {"objectKind": "COLUMN", "businessTerms": ["party size"]}), + ("SCHEMA_DOC", "crm.customers", "CRM customers table", + "Master customer records for CRM.", {"objectKind": "TABLE", "businessTerms": ["customer"]}), + ("KNOWLEDGE_ENTRY", None, "Refunds exclude gift cards", + "Business rule: refund_rate_pct must exclude gift-card redemptions.", + {"entryType": "BUSINESS_RULE"}), + ] + # Pad to requested count by cloning column docs with unique suffixes. + while len(fixtures) < args.count: + i = len(fixtures) + fixtures.append(( + "SCHEMA_DOC", + f"seed_table_{i // 10}.col_{i}", + f"Seeded column {i}", + f"Auto-seeded documentation for col_{i}.", + {"objectKind": "COLUMN", "businessTerms": [f"term_{i}"]}, + )) + + for kind, target, title, content, payload in fixtures[: args.count]: + sid = str(uuid.uuid4()) + conf = 0.99 if "refund" in (title or "").lower() or "party" in (title or "").lower() else 0.75 + linked_tables = json.dumps([target.split(".")[0]] if target and "." in target else ([target] if target else [])) + linked_cols = json.dumps([target] if target and "." in target else []) + source_files = json.dumps([{"path": "src/models/Seed.java", "startLine": 10, "endLine": 40, "rationale": "seed"}]) + payload_json = json.dumps(payload).replace("'", "''") + target_sql = "NULL" if not target else f"'{target}'" + suggestions_sql.append( + f"""INSERT INTO code_knowledge_suggestion + (id, job_id, connection_id, target_kind, target_object, title, content, payload, + linked_tables, linked_columns, source_files, confidence, status, created_at) + VALUES + ('{sid}', '{job_id}', '{conn_id}', '{kind}', {target_sql}, + '{title.replace("'", "''")}', '{content.replace("'", "''")}', + '{payload_json}'::jsonb, '{linked_tables}'::jsonb, '{linked_cols}'::jsonb, + '{source_files}'::jsonb, {conf}, 'PENDING', TIMESTAMP '{now}');""" + ) + + sql = f""" +INSERT INTO code_scan_source (id, connection_id, name, kind, archive_sha256, total_bytes, file_count, active, created_by, created_at) +VALUES ('{source_id}', '{conn_id}', '{args.name}', 'UPLOAD', 'seed', 1024, 12, TRUE, 'seed', TIMESTAMP '{now}') +ON CONFLICT (id) DO NOTHING; + +INSERT INTO code_scan_job +(id, source_id, connection_id, status, progress, current_step, files_total, files_parsed, chunks_sent, + suggestions_emitted, started_at, completed_at, message, triggered_by, created_at) +VALUES +('{job_id}', '{source_id}', '{conn_id}', 'COMPLETED', 100, 'done', 12, 12, 12, + {len(fixtures[:args.count])}, TIMESTAMP '{now}', TIMESTAMP '{now}', 'seeded', 'seed', TIMESTAMP '{now}'); + +{chr(10).join(suggestions_sql)} +""" + 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") + if rc != 0: + print(open("/tmp/seed-review.out").read(), file=sys.stderr) + return 1 + + page = req(f"{backend}/code-scan/suggestions?connectionId={conn_id}&status=PENDING&page=0&size=1") + total = page.get("totalElements") + print(f"→ PENDING totalElements={total}") + print(f"✓ Seeded source={source_id} job={job_id} suggestions={args.count}") + print(conn_id) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/components/company-knowledge/CodeSourcesTab.jsx b/src/components/company-knowledge/CodeSourcesTab.jsx index 55e45f7..4a1114a 100644 --- a/src/components/company-knowledge/CodeSourcesTab.jsx +++ b/src/components/company-knowledge/CodeSourcesTab.jsx @@ -1,5 +1,5 @@ import { useEffect, useRef, useState } from 'react' -import { useQuery } from '@tanstack/react-query' +import { useQuery, useQueryClient } from '@tanstack/react-query' import { Edit3, Loader2, RefreshCw, Save, Trash2, Upload, X } from 'lucide-react' import { codeScanAPI } from '@/lib/api/client' import { queryKeys } from '@/lib/queryKeys' @@ -42,6 +42,8 @@ function SourceCard({ source, connectionId, onDelete, externalActiveJobId }) { const inputRef = useRef(null) const [editingFocus, setEditingFocus] = useState(false) const [focusDraft, setFocusDraft] = useState(source.focusText || '') + const queryClient = useQueryClient() + const lastTerminalJobRef = useRef(null) // Pull the most-recent job for this source so we can show progress even // after a page reload, and detect any running job that wasn't started in @@ -69,6 +71,24 @@ function SourceCard({ source, connectionId, onDelete, externalActiveJobId }) { // even when the user lands on the page after a scan was already started. const effective = stream || latestJob + // When a scan finishes, refresh the Review queue. Previously only scan + // *start* invalidated queries, so the badge (fresh totalElements) could + // show 198 while the list still served a stale 2-item cache. + useEffect(() => { + if (!connectionId || !effective?.id || !effective?.status) return + if (!['COMPLETED', 'FAILED', 'CANCELLED'].includes(effective.status)) return + const key = `${effective.id}:${effective.status}:${effective.suggestionsEmitted ?? 0}` + if (lastTerminalJobRef.current === key) return + lastTerminalJobRef.current = key + queryClient.invalidateQueries({ queryKey: queryKeys.codeScan.all(connectionId) }) + }, [ + connectionId, + effective?.id, + effective?.status, + effective?.suggestionsEmitted, + queryClient, + ]) + const handleRescan = async (file) => { setError(null) try { diff --git a/src/components/company-knowledge/SuggestionsQueueTab.jsx b/src/components/company-knowledge/SuggestionsQueueTab.jsx index 1a41e0f..a15ef6d 100644 --- a/src/components/company-knowledge/SuggestionsQueueTab.jsx +++ b/src/components/company-knowledge/SuggestionsQueueTab.jsx @@ -254,11 +254,28 @@ export default function SuggestionsQueueTab({ connectionId }) { { connectionId, ids: selectedIds, decision }, { onSuccess: (data) => { - const succeeded = data?.succeeded ?? selectedIds.length + const succeeded = data?.succeeded ?? 0 const requested = data?.requested ?? selectedIds.length - setBulkSuccess( - `${decision === 'APPROVED' ? 'Approved' : 'Rejected'} ${succeeded} of ${requested}`, - ) + const failed = data?.failed ?? Math.max(0, requested - succeeded) + const verb = decision === 'APPROVED' ? 'Approved' : 'Rejected' + const summary = `${verb} ${succeeded} of ${requested}` + if (failed > 0 || succeeded === 0) { + const details = Array.isArray(data?.failures) + ? data.failures + .slice(0, 3) + .map((f) => f?.error || f?.id) + .filter(Boolean) + .join(' · ') + : '' + setBulkError( + details + ? `${summary}. ${failed} failed: ${details}` + : `${summary}. ${failed} failed — check server logs for details.`, + ) + setBulkSuccess(null) + } else { + setBulkSuccess(summary) + } setSelected(new Set()) }, onError: (err) => { @@ -279,6 +296,12 @@ export default function SuggestionsQueueTab({ connectionId }) { return next }) if (pinnedId === id) setPinnedId(null) + setBulkError(null) + setBulkSuccess(decision === 'APPROVED' ? 'Approved 1 suggestion' : 'Rejected 1 suggestion') + }, + onError: (err) => { + setBulkSuccess(null) + setBulkError(err?.response?.data?.error || err?.message || 'Decision failed') }, }, ) diff --git a/src/lib/hooks/queries/useCodeScan.js b/src/lib/hooks/queries/useCodeScan.js index 6645b7e..18ffbf2 100644 --- a/src/lib/hooks/queries/useCodeScan.js +++ b/src/lib/hooks/queries/useCodeScan.js @@ -122,6 +122,7 @@ export function useAllCodeScanSuggestions({ connectionId, status = 'PENDING' }) const pageSize = 200 // Cap to keep things bounded; one project's worst-case so far is ~750. const maxPages = 50 + let totalElements = null while (page < maxPages) { const data = await codeScanAPI.listSuggestions({ connectionId, @@ -130,14 +131,21 @@ export function useAllCodeScanSuggestions({ connectionId, status = 'PENDING' }) size: pageSize, }) const content = data?.content || [] + if (typeof data?.totalElements === 'number') { + totalElements = data.totalElements + } out.push(...content) + // Prefer server total when present so a truncated first page cannot + // silently stop early while the Review badge still shows 198. + if (totalElements != null && out.length >= totalElements) break if (content.length < pageSize) break page += 1 } return out }, enabled: Boolean(connectionId), - staleTime: 30_000, + // Badge probe and list must stay in sync after scans complete. + staleTime: 0, }) }