Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,26 @@ separately by `model.SchemaVersion` (currently 1.5.0).

## [Unreleased]

### Added
- **`pgbot sql explain "<SELECT …>"` — a query plan, read in plain English.**
Runs the plan-only form of EXPLAIN for one SELECT (`GENERIC_PLAN` on
PostgreSQL 16+, so `$1` placeholders need no values) inside a `READ ONLY`
transaction — the query is never executed — and renders the plan as a tree
with a one-line plain-English gloss per node, the planner's cost and row
estimates, and a **What stands out** list: sequential scans expected to return
many rows, nested loops that repeat a scan per outer row, sorts an index could
avoid, filters applied after an index lookup. All of it is deterministic and
offline. `--json` prints the raw `EXPLAIN (FORMAT JSON)`; `--ai` appends a
model's prose reading of the same rendered plan behind a disclosure prompt
that names what is sent (your SQL text and its plan, which unlike findings can
carry literal values). The query is the last argument or stdin; the connection
is the first argument or `$DATABASE_URL`. Only a single plain SELECT is
accepted (exit 64 otherwise). Command names are now matched
case-insensitively, so `pgbot SQL EXPLAIN …` reads like the statement it wraps.
- The MCP `explain_plan` tool now shares the same guarded path and returns the
same deterministic `observations` alongside the raw plan, plus `generic_plan`
when GENERIC_PLAN was used.

### Changed
- **PostgreSQL driver: pgx → [pgGo](https://github.com/pgrundev/pggo).** pgbot
now talks to PostgreSQL through pgGo, a dependency-free wire-protocol client
Expand Down
64 changes: 63 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,7 @@ Every command takes the connection the same way — an argument, `$DATABASE_URL`
| `activity` | live sessions right now — PIDs, ages, waits, and what each is running |
| `report` | the full inspection as one self-contained HTML page (`pgbot report > report.html`) |
| `advise` | planner-validated missing-index suggestions (needs hypopg) |
| `sql explain "<SELECT …>"` | one query's plan, read in plain English — plan-only EXPLAIN, never executed (`--ai` for a prose reading) |
| `ask "…"` · `explain` | a plain-language AI reading of the same deterministic findings |
| `explain-finding <id>` | the catalogue page for a finding, offline |
| `mcp` | serve the findings to an AI agent over MCP |
Expand Down Expand Up @@ -603,6 +604,9 @@ pgbot tables <connection-string> # largest tables + row counts + seq-vs-index
pgbot vacuum <connection-string> # autovacuum health per table — dead tuples + whether it's due
pgbot tune <connection-string> # config-tuning recommendations from the workload
pgbot explain <connection-string> # inspect, then have an AI explain the findings
pgbot sql explain "SELECT …" # one query's plan as a tree + plain English ($DATABASE_URL)
--json the raw EXPLAIN (FORMAT JSON) instead
--ai append a model's prose reading of the plan
pgbot ask "why is it slow?" # AI answer grounded on the findings ($DATABASE_URL)
--yes skip the data-disclosure confirmation prompt
pgbot mcp # run as an MCP server over stdio (for AI agents)
Expand All @@ -624,7 +628,8 @@ on stdio, so an AI agent can call pgbot as a read-only tool. It exposes
your repo; it says what to search for. Also `pgbot indexes --correlate [--json]`.
- `record_index_verdict` — store what the repo search found, so a later run over a
longer window carries strengthening evidence (no DB connection needed)
- `explain_plan` — the planner's plan for a SELECT (plain EXPLAIN, never executed)
- `explain_plan` — the planner's plan for a SELECT (plain EXPLAIN, never executed),
with the same deterministic `observations` that `pgbot sql explain` prints
- `schema_of` — a table's columns/indexes/constraints + row estimate, **no data**
- `compare_to_baseline` — the `diff`, with its interval-honesty and reset caveats
- `why` — the causal chains from stored history (symptom ← mechanism ←
Expand Down Expand Up @@ -697,6 +702,63 @@ The plugin drives the `pgbot` binary, so install that first (`curl -fsSL
https://pgbot.dev/install | sh`); set `DATABASE_URL` or pass a connection string
per call, then ask *"is my Postgres healthy?"*

### `sql explain` — read a query plan in plain English

```sh
pgbot sql explain "SELECT c.name, count(*) FROM orders o JOIN customers c ON c.id = o.customer_id
WHERE o.status = $1 GROUP BY c.name ORDER BY 2 DESC"
cat slow.sql | pgbot sql explain # the query on stdin
pgbot sql explain "$DATABASE_URL" "SELECT …" # connection first, then the query
```

`pgbot sql explain` runs the **plan-only** form of EXPLAIN for one SELECT —
inside a `READ ONLY` transaction, the query is planned and never executed — and
renders the plan the way you'd want a colleague to read it to you:

```
Query plan · estimates only — the query was planned, not executed
SELECT c.name, count(*) FROM orders o JOIN customers c ON c.id = o.customer_id …

Sort cost=562.48..567.48 rows≈2.0K
sorts about 2.0K rows by (count(*)) DESC
└─ Aggregate (hashed) cost=432.82..452.82 rows≈2.0K
groups by c.name using a hash table; expects about 2.0K rows
└─ Hash Join (inner) cost=135.96..399.49 rows≈6.7K
inner join: builds a hash table from one side and probes it with the other
Hash Cond: (o.customer_id = c.id)
├─ Bitmap Heap Scan on orders (as o) cost=79.96..325.96 rows≈6.7K
│ fetches the pages of orders marked in a bitmap by the index scan below
│ Filter: (amount > '0'::numeric)
│ └─ Bitmap Index Scan using orders_status_idx cost=0.00..78.29 rows≈6.7K
│ Index Cond: (status = $1)
└─ Hash cost=31.00..31.00 rows≈2.0K
└─ Seq Scan on customers (as c) cost=0.00..31.00 rows≈2.0K
reads every row of customers, planner expects about 2.0K rows

What stands out
· The scan via orders_status_idx on orders applies (amount > '0'::numeric) after the
index lookup, so some rows are fetched and then discarded. …

Costs and row counts are the planner's estimates, not measurements.
Planned as a generic plan: parameter values ($1, …) were unknown …
```

Each node gets one plain-English line; **What stands out** lists the shapes
worth a look — a sequential scan the planner expects to return many rows, a
nested loop that repeats a scan per outer row, a sort an index could avoid, a
filter applied after an index lookup — all derived from the planner's estimates,
offline, with no model involved. `pgbot SQL EXPLAIN …` works too (command names
are case-insensitive).

- On PostgreSQL 16+ the plan is generic (`EXPLAIN (GENERIC_PLAN)`), so `$1`-style
placeholders need no values; older servers plan queries with literal values.
- Only a **single plain SELECT** is accepted — no writes, no `WITH`, no multiple
statements (exit 64 otherwise). pgbot never uses the executing form of EXPLAIN.
- `--json` prints the raw `EXPLAIN (FORMAT JSON)` output.
- `--ai` appends a model's prose reading of the same rendered plan, behind the
disclosure prompt below. Unlike the findings, **your SQL text can carry literal
values**, so the prompt names exactly what is sent: the query and its plan.

### `explain` — optional AI layer

`pgbot explain` runs the exact same read-only inspection, prints the
Expand Down
17 changes: 12 additions & 5 deletions cmd/pgbot/explain.go
Original file line number Diff line number Diff line change
Expand Up @@ -136,18 +136,25 @@ func runExplain(cmd *cobra.Command, args []string, f inspectFlags, yes bool) err
// confirmDisclosure identifies the remote destination before data is sent.
// Local endpoints do not send findings off the machine and need no confirmation.
func confirmDisclosure(cmdName string, llm ai.LanguageModel, yes bool) bool {
return confirmDisclosureOf(cmdName, "the PII-free findings (same as `inspect --json`)", llm, yes)
}

// confirmDisclosureOf is confirmDisclosure with the payload named: `sql explain
// --ai` sends the user's SQL text and its plan, which — unlike the findings —
// can carry literal values, so the prompt must say exactly that.
func confirmDisclosureOf(cmdName, payload string, llm ai.LanguageModel, yes bool) bool {
if ai.Local(llm.Endpoint()) {
fmt.Fprintf(os.Stderr, "%s: using a local model at %s (%s) — the findings do not leave this machine.\n",
cmdName, ai.Host(llm.Endpoint()), llm.Model())
fmt.Fprintf(os.Stderr, "%s: using a local model at %s (%s) — %s does not leave this machine.\n",
cmdName, ai.Host(llm.Endpoint()), llm.Model(), payload)
return true
}
fmt.Fprintf(os.Stderr, "%s: this sends the PII-free findings (same as `inspect --json`) to %s at %s (model %s).\n",
cmdName, llm.Provider(), ai.Host(llm.Endpoint()), llm.Model())
fmt.Fprintf(os.Stderr, "%s: this sends %s to %s at %s (model %s).\n",
cmdName, payload, llm.Provider(), ai.Host(llm.Endpoint()), llm.Model())
if strings.HasPrefix(strings.ToLower(llm.Endpoint()), "http://") {
// Not local (that returned above), and not TLS: the API key and the
// findings cross the network in the clear. Say so; it is usually a LAN
// address copied from a "local model" recipe.
fmt.Fprintf(os.Stderr, "%s: WARNING — %s is plain http, so the key and the findings travel unencrypted.\n",
fmt.Fprintf(os.Stderr, "%s: WARNING — %s is plain http, so the key and the payload travel unencrypted.\n",
cmdName, ai.Host(llm.Endpoint()))
}
if yes || !isInteractive() {
Expand Down
6 changes: 6 additions & 0 deletions cmd/pgbot/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ func main() {
// way out rather than per-Target (--all-databases and `mcp` open many).
defer conn.CloseSSHTunnel()

// `pgbot SQL EXPLAIN ...` reads like the statement it wraps; cobra matches
// command names case-insensitively when this is set, so no two spellings need
// separate commands. No two pgbot commands differ only by case.
cobra.EnableCaseInsensitive = true

root := &cobra.Command{
Use: "pgbot",
Short: "In-database observability for PostgreSQL",
Expand Down Expand Up @@ -59,6 +64,7 @@ func main() {
root.AddCommand(newERDCmd())
root.AddCommand(newActivityCmd())
root.AddCommand(newReportCmd())
root.AddCommand(newSQLCmd())

// --ssh-tunnel is global: every command that takes a connection can need it,
// and it changes only HOW the DSN is reached, never what is inspected.
Expand Down
51 changes: 21 additions & 30 deletions cmd/pgbot/mcp_tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ import (
"time"

"github.com/pgrundev/pgbot/docs"
"github.com/pgrundev/pgbot/internal/advisor"
"github.com/pgrundev/pgbot/internal/conn"
"github.com/pgrundev/pgbot/internal/plan"
"github.com/pgrundev/pgbot/internal/store"
"github.com/pgrundev/pggo"
)
Expand All @@ -22,8 +22,9 @@ import (
// — a third-party client can still reword or drop it on its side.

// explainPlanTool runs a PLAIN EXPLAIN (the plan-only form) of an agent-supplied query
// through the same guard as the advisor: SanitizeSelect (single plain SELECT, no
// semicolons) plus a READ ONLY transaction. The query is planned, never executed.
// through plan.Fetch — the same guarded path as `pgbot sql explain`: SanitizeSelect
// (single plain SELECT, no semicolons) plus a READ ONLY transaction. The query is
// planned, never executed.
func explainPlanTool(ctx context.Context, args json.RawMessage) (string, error) {
var a struct {
ConnectionString string `json:"connection_string"`
Expand All @@ -33,9 +34,8 @@ func explainPlanTool(ctx context.Context, args json.RawMessage) (string, error)
if strings.TrimSpace(a.Query) == "" {
return "", fmt.Errorf("query is required")
}
clean := advisor.SanitizeSelect(a.Query)
if clean == "" {
return "", fmt.Errorf("only a single plain SELECT can be explained — no writes, no multiple statements, no utility statements")
if _, err := plan.Prepare(a.Query, 160000); err != nil {
return "", err
}
dsn, err := dsnFromArgs(args)
if err != nil {
Expand All @@ -49,35 +49,26 @@ func explainPlanTool(ctx context.Context, args json.RawMessage) (string, error)
}
defer target.Close()

// GENERIC_PLAN (PG16+) plans a query with $N placeholders without values;
// below 16, plain EXPLAIN works for a query with literal values and errors
// clearly on one with placeholders.
stmt := "EXPLAIN (FORMAT JSON) " + clean
if target.Caps.VersionNum >= 160000 {
stmt = "EXPLAIN (GENERIC_PLAN, FORMAT JSON) " + clean
}
var planJSON []byte
err = target.ReadOnlyTx(ctx, func(tx *pggo.Tx) error {
res, e := tx.Conn().SimpleQuery(ctx, stmt)
if e != nil {
return e
}
for _, r := range res {
if len(r.Rows) > 0 && len(r.Rows[0]) > 0 {
planJSON = r.Rows[0][0]
return nil
}
}
return fmt.Errorf("no plan returned")
})
planJSON, generic, err := plan.Fetch(ctx, target, a.Query)
if err != nil {
return "", fmt.Errorf("explain: %s", conn.RedactConnString(err.Error()))
return "", err
}
return marshal(map[string]any{
out := map[string]any{
"plan": json.RawMessage(planJSON),
"exactness": "estimate",
"note": "plain EXPLAIN — the query was planned, NOT executed. Costs and row counts are the planner's ESTIMATES, not measurements; state them as such.",
})
}
if root, perr := plan.Parse(planJSON); perr == nil {
var obs []map[string]string
for _, o := range plan.Observe(root) {
obs = append(obs, map[string]string{"level": o.Level, "text": o.Text})
}
out["observations"] = obs
}
if generic {
out["generic_plan"] = true
}
return marshal(out)
}

// compareToBaselineTool is `pgbot diff` as a tool: it reads the local baseline
Expand Down
Loading
Loading