Adds a self-hosted (Ollama, qwen2.5-coder) model provider abstraction with a pluggable opt-in cloud adapter, schema grounding, and a shared cost/safety guard every AI-suggested query is assessed against -- compiling to and executing through the same unchanged Phase 2 IR/ compiler and Phase 4 tenant scoping as a hand-written query, no parallel execution path. Track A (built into the query bar): inline ghost-text autocomplete, "Explain this query", "Fix this query" with a diff view, and a rule-based "Optimize" suggestion. Track B: natural-language-to-query translation, always a separate review step from execution, with `sentryctl query --nl` requiring explicit confirmation to run. Every accepted/dismissed translate-fix-optimize interaction is logged into the same append-only audit_log table Phase 4 built. Two real product bugs were found and fixed via live browser verification (a Svelte effect re-running on every keystroke that silently cancelled the ghost-text debounce; a ghost-text widget positioned at document offset 0 instead of the cursor), and a real costguard logic bug (unbounded-aggregation vs. raw-row) was caught by its own test suite. New integration tests wire a real Ollama client through the real HTTP handler against a mock server matching Ollama's wire contract (hack/mock-ollama), keeping model-quality verification out of CI as a disclosed, periodic human-run check instead. See /docs/phase-7-ai-design.md and /docs/phase-7-runbook.md.
276 lines
8.0 KiB
Go
276 lines
8.0 KiB
Go
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
type queryArgs struct {
|
|
apiURL string
|
|
jsonOut bool
|
|
language string
|
|
query string
|
|
// nlQuery, execute (Phase 7 task 11): --nl routes through
|
|
// POST /ai/translate instead of running query text directly.
|
|
// execute is the same "explicit opt-in to actually run this"
|
|
// posture the web UI's confirm-to-run action enforces -- a
|
|
// translated query never runs itself, here or there.
|
|
nlQuery string
|
|
execute bool
|
|
}
|
|
|
|
// parseQueryArgs is pure (env passed in, no I/O), same testability
|
|
// reasoning as parsePingArgs. Non-flag arguments are joined with spaces
|
|
// to form the query, so `sentryctl query service=api status=500` (no
|
|
// quotes, no shell-special characters) works without requiring users to
|
|
// quote every query -- though anything using "|" still needs shell
|
|
// quoting regardless, since that's a real shell pipe character otherwise.
|
|
func parseQueryArgs(args []string, env func(string) string) queryArgs {
|
|
qa := queryArgs{apiURL: resolveAPIURL(env)}
|
|
var rest []string
|
|
for i := 0; i < len(args); i++ {
|
|
switch args[i] {
|
|
case "--api":
|
|
if i+1 < len(args) {
|
|
qa.apiURL = args[i+1]
|
|
i++
|
|
}
|
|
case "--json":
|
|
qa.jsonOut = true
|
|
case "--language":
|
|
if i+1 < len(args) {
|
|
qa.language = args[i+1]
|
|
i++
|
|
}
|
|
case "--nl":
|
|
if i+1 < len(args) {
|
|
qa.nlQuery = args[i+1]
|
|
i++
|
|
}
|
|
case "--execute":
|
|
qa.execute = true
|
|
default:
|
|
rest = append(rest, args[i])
|
|
}
|
|
}
|
|
qa.query = strings.Join(rest, " ")
|
|
return qa
|
|
}
|
|
|
|
type queryRequestBody struct {
|
|
Query string `json:"query"`
|
|
Language string `json:"language"`
|
|
}
|
|
|
|
type queryResponseBody struct {
|
|
Columns []string `json:"columns"`
|
|
Rows [][]any `json:"rows"`
|
|
Warnings []string `json:"warnings"`
|
|
}
|
|
|
|
type translateRequestBody struct {
|
|
NLQuery string `json:"nlQuery"`
|
|
}
|
|
|
|
type translateResponseBody struct {
|
|
Query string `json:"query"`
|
|
Confidence string `json:"confidence"`
|
|
LowConfidenceReason string `json:"lowConfidenceReason"`
|
|
Compiles bool `json:"compiles"`
|
|
CompileError string `json:"compileError"`
|
|
Blocked bool `json:"blocked"`
|
|
CostWarnings []string `json:"costWarnings"`
|
|
}
|
|
|
|
func cmdQuery(args []string, stdout, stderr io.Writer) int {
|
|
qa := parseQueryArgs(args, os.Getenv)
|
|
|
|
if qa.nlQuery != "" {
|
|
return cmdQueryNL(qa, stdout, stderr, os.Stdin)
|
|
}
|
|
|
|
if strings.TrimSpace(qa.query) == "" {
|
|
fmt.Fprintln(stderr, "sentryctl query: missing query string")
|
|
return 1
|
|
}
|
|
return runAndPrintQuery(qa.apiURL, qa.query, qa.language, qa.jsonOut, stdout, stderr)
|
|
}
|
|
|
|
// cmdQueryNL implements --nl: translate, show the result, then only run
|
|
// it with explicit opt-in (--execute, or an interactive "y" confirmation
|
|
// -- never a bare unattended run). stdin is a parameter (not read from
|
|
// os.Stdin directly) so the confirmation prompt is testable the same
|
|
// way parseQueryArgs's env injection is.
|
|
func cmdQueryNL(qa queryArgs, stdout, stderr io.Writer, stdin io.Reader) int {
|
|
reqBody, err := json.Marshal(translateRequestBody{NLQuery: qa.nlQuery})
|
|
if err != nil {
|
|
fmt.Fprintf(stderr, "encoding request: %v\n", err)
|
|
return 1
|
|
}
|
|
req, err := http.NewRequest(http.MethodPost, qa.apiURL+"/ai/translate", bytes.NewReader(reqBody))
|
|
if err != nil {
|
|
fmt.Fprintf(stderr, "building request: %v\n", err)
|
|
return 1
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
setAuth(req, resolveToken(os.Getenv))
|
|
|
|
client := &http.Client{Timeout: 30 * time.Second}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
fmt.Fprintf(stderr, "translation failed: %v\n", err)
|
|
return 1
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
respBody, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
fmt.Fprintf(stderr, "reading response: %v\n", err)
|
|
return 1
|
|
}
|
|
if resp.StatusCode != http.StatusOK {
|
|
var errResp errorResponseBody
|
|
if json.Unmarshal(respBody, &errResp) == nil && errResp.Error != "" {
|
|
fmt.Fprintf(stderr, "translation failed: %s\n", errResp.Error)
|
|
} else {
|
|
fmt.Fprintf(stderr, "translation failed: api returned status %d\n", resp.StatusCode)
|
|
}
|
|
return 1
|
|
}
|
|
|
|
var t translateResponseBody
|
|
if err := json.Unmarshal(respBody, &t); err != nil {
|
|
fmt.Fprintf(stderr, "decoding response: %v\n", err)
|
|
return 1
|
|
}
|
|
|
|
if t.Query == "" {
|
|
reason := t.LowConfidenceReason
|
|
if reason == "" {
|
|
reason = "the model did not return a query"
|
|
}
|
|
fmt.Fprintf(stdout, "No confident translation available: %s\n", reason)
|
|
return 1
|
|
}
|
|
|
|
fmt.Fprintf(stdout, "Translated query (%s confidence):\n %s\n", t.Confidence, t.Query)
|
|
if !t.Compiles {
|
|
fmt.Fprintf(stdout, "This does not parse as a valid query: %s\n", t.CompileError)
|
|
fmt.Fprintln(stdout, "Not running it -- copy, fix, and run manually if you want to use it.")
|
|
return 1
|
|
}
|
|
if len(t.CostWarnings) > 0 {
|
|
fmt.Fprintf(stdout, "Cost guard: %s\n", strings.Join(t.CostWarnings, "; "))
|
|
}
|
|
if t.Blocked {
|
|
fmt.Fprintln(stdout, "Not offered as directly runnable -- copy and adjust manually if you want to use it.")
|
|
return 1
|
|
}
|
|
|
|
if !qa.execute {
|
|
if !isInteractive(stdin) {
|
|
fmt.Fprintln(stdout, "Not running (pass --execute to run automatically, or run this interactively to confirm).")
|
|
return 0
|
|
}
|
|
if !confirmRun(stdin, stdout, "Run this query?") {
|
|
fmt.Fprintln(stdout, "Not running.")
|
|
return 0
|
|
}
|
|
}
|
|
|
|
return runAndPrintQuery(qa.apiURL, t.Query, "spl", qa.jsonOut, stdout, stderr)
|
|
}
|
|
|
|
// confirmRun prompts stdin for a y/N answer -- only "y"/"yes"
|
|
// (case-insensitive) counts as confirmation, matching the web UI's
|
|
// posture that running an AI-generated query is opt-in, never a
|
|
// default a blank Enter press could accidentally trigger.
|
|
func confirmRun(stdin io.Reader, stdout io.Writer, prompt string) bool {
|
|
fmt.Fprintf(stdout, "%s [y/N] ", prompt)
|
|
line, _ := bufio.NewReader(stdin).ReadString('\n')
|
|
answer := strings.ToLower(strings.TrimSpace(line))
|
|
return answer == "y" || answer == "yes"
|
|
}
|
|
|
|
// isInteractive reports whether stdin looks like a real terminal --
|
|
// used to decide whether a confirmation prompt makes sense at all
|
|
// (a non-interactive/piped invocation with no --execute would otherwise
|
|
// hang forever waiting for an answer nobody can give; refusing to run
|
|
// and exiting cleanly is the safe default there, matching --execute's
|
|
// own opt-in-required posture rather than silently running).
|
|
func isInteractive(stdin io.Reader) bool {
|
|
f, ok := stdin.(*os.File)
|
|
if !ok {
|
|
return false
|
|
}
|
|
info, err := f.Stat()
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return info.Mode()&os.ModeCharDevice != 0
|
|
}
|
|
|
|
func runAndPrintQuery(apiURL, query, language string, jsonOut bool, stdout, stderr io.Writer) int {
|
|
reqBody, err := json.Marshal(queryRequestBody{Query: query, Language: language})
|
|
if err != nil {
|
|
fmt.Fprintf(stderr, "encoding request: %v\n", err)
|
|
return 1
|
|
}
|
|
|
|
req, err := http.NewRequest(http.MethodPost, apiURL+"/query", bytes.NewReader(reqBody))
|
|
if err != nil {
|
|
fmt.Fprintf(stderr, "building request: %v\n", err)
|
|
return 1
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
setAuth(req, resolveToken(os.Getenv))
|
|
|
|
client := &http.Client{Timeout: 30 * time.Second}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
fmt.Fprintf(stderr, "query failed: %v\n", err)
|
|
return 1
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
respBody, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
fmt.Fprintf(stderr, "reading response: %v\n", err)
|
|
return 1
|
|
}
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
var errResp errorResponseBody
|
|
if json.Unmarshal(respBody, &errResp) == nil && errResp.Error != "" {
|
|
fmt.Fprintf(stderr, "query failed: %s\n", errResp.Error)
|
|
} else {
|
|
fmt.Fprintf(stderr, "query failed: api returned status %d\n", resp.StatusCode)
|
|
}
|
|
return 1
|
|
}
|
|
|
|
if jsonOut {
|
|
_, _ = stdout.Write(respBody)
|
|
fmt.Fprintln(stdout)
|
|
return 0
|
|
}
|
|
|
|
var result queryResponseBody
|
|
if err := json.Unmarshal(respBody, &result); err != nil {
|
|
fmt.Fprintf(stderr, "decoding response: %v\n", err)
|
|
return 1
|
|
}
|
|
printTable(stdout, result.Columns, result.Rows)
|
|
if len(result.Warnings) > 0 {
|
|
fmt.Fprintf(stdout, "\nWarning: %s\n", strings.Join(result.Warnings, "; "))
|
|
}
|
|
return 0
|
|
}
|