Skip to content

Commit 6e74e3e

Browse files
Merge branch 'main' into kaushik-IDE
2 parents 29dad06 + ecf954f commit 6e74e3e

55 files changed

Lines changed: 3472 additions & 132 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,3 +104,7 @@ optd-sidecar/target/
104104
*.iml
105105
.local-admin-credentials
106106
.local-mcp-token
107+
108+
# Python bytecode from scripts/
109+
__pycache__/
110+
*.pyc

AGENTS.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,11 @@ only covers cloud-specific, non-obvious caveats.
212212
`sudo -u postgres psql -f docker/postgres/init/11_create_acme_erp.sql` then
213213
`bash scripts/seed-acme-erp.sh` (registers `ACME ERP (Multi-Schema)` when backend auth
214214
is disabled or you have an admin session cookie).
215+
- **Company Knowledge → Review queue** (code-scan suggestions): seed without an LLM via
216+
`python3 scripts/self-host/seed-review-suggestions.py --count 50`, then exercise approve/
217+
reject/bulk edge cases with `python3 scripts/self-host/e2e-review-approvals.py`.
218+
Approving `SCHEMA_DOC` rows needs `CODE_DERIVED` on `schema_documentation_source_check`
219+
(startup initializer repairs this; Hibernate `ddl-auto` does not).
215220

216221
### Non-obvious setup caveats (each cost real debugging time)
217222

CLAUDE.md

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -388,6 +388,15 @@ The Agent tab must not inherit the admin MCP token. `/api/agent/session` mints a
388388
resilience but only ever handled a *moved ref*, re-issuing the identical refused
389389
request against a 429. A fallback that fails the same way as the thing it backs
390390
up is not a fallback.
391+
7. **Never offer a write the caller cannot enforce.** `SOUL.md` once asked
392+
"Should everyone on this database see this?" after every good answer, so
393+
Agent chat offered "save this as a shared DeepSQL brain note" to users
394+
without `canManageContent` and then 403'd. `get_brain_context` now stamps
395+
`callerCapabilities`; if `doNotOffer` includes `save_brain_note`, the
396+
agent must not mention it. MCP `save_brain_note` also fail-closes before
397+
the POST. Admins get a non-blocking suggestion bubble only after they
398+
correct or teach the Agent (`POST /brain/notes/propose` + accept) — a
399+
clean first answer stays quiet. Overlaps merge into one intent.
391400

392401
### Verification Anti-Patterns (do not repeat)
393402

@@ -406,6 +415,16 @@ broken. Assert the *outcome*, never the attempt:
406415
- **Mocks hide SDK breaks.** `tests/tools/test_mcp_structured_content.py` uses a
407416
`_FakeCallToolResult` with a hardcoded `.isError`, so it kept passing precisely when
408417
the real SDK stopped matching. Pin the dependency; a fake cannot catch this.
418+
- **A self-host verification script must reach the DB the way the install does.**
419+
`seed-review-suggestions.py` / `e2e-review-approvals.py` hardcoded
420+
`sudo -u postgres psql`, which only exists on a bare-metal install — on the Compose
421+
deployment `install.sh` actually produces, the documented verify command died before
422+
testing anything. Both now resolve the path through `scripts/self-host/vaultdb.py`.
423+
- **A test that mutates shared state must restore it, and only what it created.** The
424+
same e2e suite parked every real `CODE_DERIVED` row by rewriting `source` to `USER`
425+
and never restored it, so a run against a live install silently relabelled the user's
426+
approved docs. It now copies rows to a scratch table and restores them, and its
427+
cleanup deletes the planted row only while nothing references it.
409428
- **Never claim a check you did not run.** `install.sh` reported "up to date" when it
410429
could not reach npm; it now says it could not check.
411430
- **`set -e` + `read` at EOF aborts silently.** Prompts in `install.sh` use
@@ -494,6 +513,53 @@ it against a real database — not a theoretical hardening pass.
494513
`POST /users/admin/reset` on every install that had run `setup-agent.sh`, since that
495514
mints an admin MCP token on each run.
496515

516+
- **An `Optional`-returning derived finder is an assertion that the key is unique.**
517+
Spring Data throws `IncorrectResultSizeDataAccessException` ("Query did not return a
518+
unique result: N results were returned") the moment it is not, and the row that broke
519+
it never repairs itself, so the failure is permanent rather than transient.
520+
`schema_documentation` had no unique constraint on
521+
`(connection_id, object_type, object_name, parent_object, source)` and
522+
`CodeSuggestionApplier.approve` took no row lock, so one bulk approve submitted twice
523+
concurrently wrote 219 duplicate pairs. Every later approve touching one of those keys
524+
threw, `CodeScanService.bulkDecide` swallowed it per item, and the Review queue
525+
reported "Approved 0 of 2" — with all 198 pending SCHEMA_DOC suggestions wedged.
526+
Three-part fix, and all three are load-bearing:
527+
1. `V116__dedupe_schema_documentation.sql` + `SchemaDocumentationDedupeInitializer`
528+
(no Flyway here, so the initializer is what actually applies it) collapse the
529+
duplicates and add `ux_schema_doc_target`, keyed on
530+
`coalesce(parent_object,'')` because Postgres treats NULLs as distinct.
531+
2. Those finders now return `List`, and `SchemaDocumentationDeduplicator.collapse`
532+
keeps the newest row, repoints any `applied_doc_id` off the rows it deletes, and
533+
drops their RAG embeddings. Do not restore an `Optional` variant — legacy installs
534+
still carry duplicates until the initializer runs.
535+
3. `approve`/`reject` load the suggestion via `findByIdForUpdate` (`PESSIMISTIC_WRITE`)
536+
so the concurrent double-submit that created the duplicates blocks instead of racing.
537+
- **`applied_doc_id` is a loose reference, not an FK.** Deleting a `schema_documentation`
538+
row it points at raises nothing and dangles silently — repoint before deleting.
539+
- **Approve *updates* the row an earlier scan wrote**, so a "freshly approved" doc row
540+
carries a historical `created_at`. A test that plants an "old" duplicate with a
541+
hardcoded past date can easily plant the *newer* of the two and assert nothing; anchor
542+
fixture timestamps to the real row's `created_at`.
543+
- **A write's blast radius decides what to invalidate, not the endpoint you called.**
544+
Approving a code-scan suggestion writes `code_knowledge_suggestion` *and*
545+
`schema_documentation` (served by `brain/notes`, which backs the Write-notes tab
546+
and its coverage counts) *and* `rag_documents` *and*, for KNOWLEDGE_ENTRY, a
547+
company knowledge entry. The decide hooks invalidated only `codeScan` +
548+
`companyKnowledge`, so every schema-doc-derived count stayed stale until the user
549+
reloaded the page. `invalidateAfterDecision` in `useCodeScan.js` is the single
550+
place that lists them; add to it when an approval starts writing something new.
551+
- **`@PreUpdate` does not fire on insert, so `updatedAt` is null on a brand-new row.**
552+
Sorting "newest first" on `updatedAt` alone with nulls last therefore sends every
553+
freshly created row to the *bottom* — which is why a just-approved note did not
554+
appear at the top of the Write-notes list. Sort on
555+
`COALESCE(updatedAt, createdAt)` (`BrainNoteService.touchedAt`,
556+
`CompanyKnowledgeEntryRepository.findByConnectionIdOrderByRecency`).
557+
- **Suggestion list order depends on the status being viewed.** PENDING is a work
558+
queue → `confidence DESC`. APPROVED/REJECTED are history → `decidedAt DESC NULLS
559+
LAST` so the decision you just made is at the top; confidence-sorting a decided
560+
list scattered fresh approvals among hundreds of older ones
561+
(`CodeScanService.sortFor`).
562+
497563
### Endpoint Authorization Rules
498564

499565
- **Authentication is not authorization.** `SecurityConfig` only asserts

agent/SOUL.md

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ You are **DeepSQL DBA**, an AI database performance assistant. You answer questi
22

33
**Lead with the answer.** You ground thoroughly with the tools, but you do **not** narrate that work in your reply. No "I checked / I joined…", no "Grounding used", no "Filters applied", no "Used:" footnotes, no column/filter walkthroughs. Answer with just the result — a number, a short ranked table, or a one-line sentence — and apply business rules silently. Tool steps already show what ran; don't repeat that in the bubble.
44

5-
After the answer you may offer **one short follow-up question** (a single line) when it helps the user go deeper. Do not stack multiple offers. If the user wants the SQL, the tables, or how you got there, they'll ask, and then you show it. Admit uncertainty instead of guessing; prefer one correct answer over a verbose survey.
5+
After the answer you may offer **one short follow-up question** (a single line) when it helps the user go deeper — a question they can answer, not an action they cannot take. Do not stack multiple offers. If the user wants the SQL, the tables, or how you got there, they'll ask, and then you show it. Admit uncertainty instead of guessing; prefer one correct answer over a verbose survey.
66

77
(Exception: the schema-consult flow in rule 8 — when proposing a table/migration you DO briefly state what already exists, because that's the point of the consult.)
88

@@ -24,25 +24,35 @@ After the answer you may offer **one short follow-up question** (a single line)
2424

2525
8. **Consult before you commit schema.** When the user says "add a table / track X / write a migration," STOP and run the brain consult (`get_brain_context``get_schema``list_business_rules``get_relationships``get_anti_patterns`). There is almost always an existing table or column to extend instead of duplicate. Narrate what you found before proposing DDL.
2626

27+
9. **Never offer an action the caller cannot enforce.** `get_brain_context` and
28+
`list_connections` carry `callerCapabilities`. If `doNotOffer` lists an
29+
action — especially `save_brain_note` — do not mention it, do not ask
30+
"should I save this", and do not render a Yes button. Answering a metric
31+
is not a request to persist it. The product UI may show a non-blocking
32+
save bubble after the user corrects or teaches a definition; leave that
33+
to the UI. Never volunteer it yourself.
34+
2735
## Remembering things — two different places
2836

29-
There are TWO planes of memory. Route every "remember this" to the right one:
37+
There are TWO planes of memory. Route a remember request only when the user
38+
explicitly asked to remember / pin / save a definition:
3039

3140
1. **Company brain context (shared).** Durable facts about the *data* — what a
3241
column means, a join path, a business definition, an accepted recommendation.
3342
These ground EVERYONE's answers on this connection. Save them with
34-
**`save_brain_note(connectionId, tableName, noteText, columnName?)`**.
35-
- "Accept this recommendation" / "remember this for the team" → review with
36-
**`list_brain_recommendations`**, then `save_brain_note` for each good one.
37-
- This is **admin-only** (manage-content) and audited. If the user lacks
38-
permission, the backend rejects it — say so, don't work around it.
43+
**`save_brain_note(connectionId, tableName, noteText, columnName?)`**
44+
**only if** `callerCapabilities.canWriteSharedBrainNotes` is true
45+
(`list_connections.canManageContent`).
46+
- If they asked to remember and they cannot write: tell them an admin with
47+
manage-content on this connection has to save it. Do not call the tool.
3948
2. **Individual preference (yours alone).** How *this* user likes answers
4049
formatted, a private shortcut, a personal default. That is a **DeepSQL
4150
skill** on the user's own profile — it does NOT belong in the shared brain.
4251
Never push a personal preference into `save_brain_note`.
4352

44-
When unsure which plane a request belongs to, ask: "Should everyone on this
45-
database see this, or just you?" Shared → brain note. Just you → DeepSQL skill.
53+
Do not volunteer a shared-brain save after answering a data question. Do not
54+
ask "should everyone on this database see this?" unless the user already asked
55+
to remember something **and** they can write shared notes.
4656

4757
## Skills
4858

agent/skills/bi-query/SKILL.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ Use when the user asks a question whose answer is **in the data** ("how many boo
2727

2828
6. **Run it** with `execute_sql(connectionId, sql, limit=…)`. Remember: default 100 rows, max 1000. For a total, `SELECT COUNT(*)` rather than counting a truncated result set.
2929

30-
7. **Answer only.** Reply with just the result — the number or a short ranked table — then optionally **one** short follow-up question. Apply business rules silently; do NOT append "Grounding used" / "Filters applied" / "Used:" / tool-narration / column-mapping sections. Only if the user asks how you got it do you show the tables, joins, and filters.
30+
7. **Answer only.** Reply with just the result — the number or a short ranked table — then optionally **one** short follow-up question the user can actually act on. Do **not** offer to save a shared brain note, apply an index, or run DDL/DML unless `get_brain_context.callerCapabilities` says they can. Apply business rules silently; do NOT append "Grounding used" / "Filters applied" / "Used:" / tool-narration / column-mapping sections. Only if the user asks how you got it do you show the tables, joins, and filters.
3131

3232
## Guardrails
3333

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
package com.dbaagent.config;
2+
3+
import lombok.extern.slf4j.Slf4j;
4+
import org.springframework.context.annotation.Bean;
5+
import org.springframework.context.annotation.Configuration;
6+
import org.springframework.context.annotation.DependsOn;
7+
import org.springframework.jdbc.core.JdbcTemplate;
8+
import org.springframework.transaction.support.TransactionTemplate;
9+
import org.springframework.transaction.PlatformTransactionManager;
10+
11+
import javax.sql.DataSource;
12+
13+
/**
14+
* Applies {@code V116__dedupe_schema_documentation.sql} at startup: collapses
15+
* duplicate {@code schema_documentation} rows and adds the unique index on the
16+
* logical key.
17+
*
18+
* <p>This repo has no Flyway runtime — {@code db/migration} is a hand-maintained
19+
* changelog and Hibernate {@code ddl-auto=update} never adds an index the entity
20+
* does not declare. Without this, self-host installs carrying duplicates from a
21+
* double-submitted bulk approve stay wedged: every SCHEMA_DOC approve throws
22+
* {@code Query did not return a unique result}. Mirrors
23+
* {@link SchemaDocumentationSourceCompatibilityInitializer}.
24+
*
25+
* <p>Idempotent and cheap on a clean install: the index exists, so it returns
26+
* before touching a row.
27+
*/
28+
@Configuration
29+
@Slf4j
30+
public class SchemaDocumentationDedupeInitializer {
31+
32+
private static final String TABLE = "schema_documentation";
33+
private static final String INDEX = "ux_schema_doc_target";
34+
35+
@Bean("schemaDocumentationDedupeBootstrap")
36+
@DependsOn("entityManagerFactory")
37+
public Object schemaDocumentationDedupeBootstrap(DataSource dataSource,
38+
PlatformTransactionManager txManager) {
39+
JdbcTemplate jdbc = new JdbcTemplate(dataSource);
40+
if (!tableExists(jdbc, TABLE)) {
41+
return new Object();
42+
}
43+
if (indexExists(jdbc, INDEX)) {
44+
return new Object();
45+
}
46+
47+
// One transaction: a half-applied dedupe (rows deleted, index missing)
48+
// would silently re-accumulate duplicates until the next boot.
49+
new TransactionTemplate(txManager).executeWithoutResult(status -> {
50+
int repointed = jdbc.update("""
51+
UPDATE code_knowledge_suggestion s
52+
SET applied_doc_id = l.keep_id
53+
FROM (%s) l
54+
WHERE s.applied_doc_id = l.id
55+
""".formatted(LOSERS));
56+
57+
int embeddings = jdbc.update(
58+
"DELETE FROM rag_documents WHERE id IN (SELECT id FROM (%s) l)".formatted(LOSERS));
59+
60+
int removed = jdbc.update(
61+
"DELETE FROM schema_documentation WHERE id IN (SELECT id FROM (%s) l)".formatted(LOSERS));
62+
63+
jdbc.execute("""
64+
CREATE UNIQUE INDEX IF NOT EXISTS %s
65+
ON %s (connection_id, object_type, object_name, coalesce(parent_object, ''), source)
66+
""".formatted(INDEX, TABLE));
67+
68+
if (removed > 0) {
69+
log.warn("Deduped {}: removed {} duplicate rows, {} orphaned embeddings, "
70+
+ "repointed {} applied_doc_id references",
71+
TABLE, removed, embeddings, repointed);
72+
}
73+
log.info("Ensured unique index {} on {}", INDEX, TABLE);
74+
});
75+
return new Object();
76+
}
77+
78+
/**
79+
* Every row but the newest within each logical key. Newest wins because it is
80+
* the row existing {@code applied_doc_id} references point at; {@code id}
81+
* breaks ties for rows written in the same clock tick. {@code coalesce} on
82+
* {@code parent_object} because Postgres treats NULLs as distinct, so TABLE
83+
* rows would otherwise never group together.
84+
*/
85+
private static final String LOSERS = """
86+
SELECT id, keep_id FROM (
87+
SELECT id,
88+
first_value(id) OVER w AS keep_id,
89+
row_number() OVER w AS rn
90+
FROM schema_documentation
91+
WINDOW w AS (
92+
PARTITION BY connection_id, object_type, object_name,
93+
coalesce(parent_object, ''), source
94+
ORDER BY created_at DESC NULLS LAST, id DESC
95+
)
96+
) ranked WHERE rn > 1
97+
""";
98+
99+
private boolean tableExists(JdbcTemplate jdbc, String tableName) {
100+
Integer count = jdbc.queryForObject("""
101+
SELECT COUNT(*)
102+
FROM information_schema.tables
103+
WHERE table_schema = 'public' AND table_name = ?
104+
""", Integer.class, tableName);
105+
return count != null && count > 0;
106+
}
107+
108+
private boolean indexExists(JdbcTemplate jdbc, String indexName) {
109+
Integer count = jdbc.queryForObject("""
110+
SELECT COUNT(*)
111+
FROM pg_indexes
112+
WHERE schemaname = 'public' AND indexname = ?
113+
""", Integer.class, indexName);
114+
return count != null && count > 0;
115+
}
116+
}

0 commit comments

Comments
 (0)