Files
cairnobs/enterprise/internal/groundingregistry/registry.go
T
jcoffey-dev 7d316f92db Phase 7: AI-assisted query authoring (autocomplete, explain, fix, optimize, NL translation)
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.
2026-08-16 18:06:27 -07:00

145 lines
5.4 KiB
Go

// Package groundingregistry gives each active tenant its own schema-
// grounding snapshot (Phase 7 task 3) in a multi-tenant deployment,
// mirroring enterprise/internal/chwriter.Registry's per-tenant-instance
// shape. It exists because api/ai/grounding.Service is deliberately
// tenant-agnostic (it just wraps whatever executor.SQLRunner it's given
// and caches one snapshot) -- a multi-tenant deployment needs many
// snapshots, one per tenant, refreshed independently.
//
// The underlying SQLRunner every tenant's Service samples through is the
// *same* chrunner.Registry instance shared across all of them: chrunner
// resolves which tenant's actual ClickHouse connection to use from the
// context.Context passed to RunSQL, not from anything this package
// stores per tenant -- see chrunner.Registry.RunSQL's doc comment. So
// "one grounding.Service per tenant" doesn't mean one ClickHouse
// connection per tenant here (chrunner already owns that); it means one
// cached snapshot per tenant, refreshed by calling that tenant's
// Service.Refresh with a context stamped with that tenant's identity via
// api/authz.WithIdentity -- the same "construct our own request context
// outside an HTTP handler" pattern that function's doc comment names
// this exact kind of caller as being for.
package groundingregistry
import (
"context"
"log/slog"
"sync"
"time"
"github.com/sentry/sentry/api/ai/grounding"
"github.com/sentry/sentry/api/ai/provider"
"github.com/sentry/sentry/api/authz"
"github.com/sentry/sentry/api/querylang/executor"
)
// TenantLister returns the currently-active tenant IDs to sample --
// a narrow function type rather than an rbacstore dependency, same
// reasoning chwriter.Registry's SourceLister gives: this package
// shouldn't need to import rbacstore just to know its return type.
// enterprise-api's main.go supplies one backed by
// rbacstore.ListProvisionedDataSources, the same source chrunner/
// chwriter's own registries already refresh from.
type TenantLister func(ctx context.Context) ([]string, error)
// Registry holds one grounding.Service per active tenant, all sharing
// the same underlying SQLRunner (chrunner.Registry).
type Registry struct {
runner executor.SQLRunner
mu sync.RWMutex
services map[string]*grounding.Service
}
func New(runner executor.SQLRunner) *Registry {
return &Registry{runner: runner, services: make(map[string]*grounding.Service)}
}
// SchemaContextFor returns tenant's cached grounding snapshot, or a
// zero-valued SchemaContext if that tenant hasn't been sampled yet (new
// tenant, not yet seen by a refresh cycle) -- same "absence is normal,
// not an error" posture grounding.Service.Current documents.
func (r *Registry) SchemaContextFor(tenantID string) provider.SchemaContext {
r.mu.RLock()
svc, ok := r.services[tenantID]
r.mu.RUnlock()
if !ok {
return provider.SchemaContext{}
}
return svc.Current()
}
// SchemaContext implements aiapi.SchemaContextSource, resolving the
// tenant from ctx the same way chrunner.RunSQL does -- the multi-tenant
// counterpart to grounding.Service's own same-named method, which has
// no tenant to resolve in a single-tenant deployment. An unauthenticated
// or tenant-less context (shouldn't happen behind aiapi's RoleViewer
// auth wrapper, but handled rather than assumed) returns a zero-valued
// SchemaContext, same as an unseen tenant -- absence is normal here, not
// worth a panic or a swallowed error over.
func (r *Registry) SchemaContext(ctx context.Context) provider.SchemaContext {
id, ok := authz.IdentityFromContext(ctx)
if !ok || id.TenantID == "" {
return provider.SchemaContext{}
}
return r.SchemaContextFor(id.TenantID)
}
// StartRefreshing lists active tenants and refreshes each one's
// grounding snapshot, immediately and then on interval, until ctx is
// cancelled -- same shape as chwriter.Registry.StartRefreshing. A
// newly-active tenant gets a Service the first time it appears in
// lister's output; a tenant that's no longer listed keeps its last
// snapshot rather than being torn down (grounding data going briefly
// stale for a deprovisioned tenant is harmless -- unlike a ClickHouse
// writer connection, there's no credential to leak or clean up here).
func (r *Registry) StartRefreshing(ctx context.Context, lister TenantLister, interval time.Duration, logger *slog.Logger) {
r.refreshAll(ctx, lister, logger)
go func() {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
r.refreshAll(ctx, lister, logger)
}
}
}()
}
func (r *Registry) refreshAll(ctx context.Context, lister TenantLister, logger *slog.Logger) {
tenantIDs, err := lister(ctx)
if err != nil {
if logger != nil {
logger.Error("groundingregistry: listing active tenants", "error", err)
}
return
}
for _, tenantID := range tenantIDs {
svc := r.serviceFor(tenantID)
tenantCtx := authz.WithIdentity(ctx, authz.Identity{TenantID: tenantID, Role: authz.RoleService})
if err := svc.Refresh(tenantCtx); err != nil && logger != nil {
logger.Error("groundingregistry: refreshing tenant", "tenant", tenantID, "error", err)
}
}
}
func (r *Registry) serviceFor(tenantID string) *grounding.Service {
r.mu.RLock()
svc, ok := r.services[tenantID]
r.mu.RUnlock()
if ok {
return svc
}
r.mu.Lock()
defer r.mu.Unlock()
if svc, ok := r.services[tenantID]; ok { // re-check under write lock
return svc
}
svc = grounding.New(r.runner)
r.services[tenantID] = svc
return svc
}