a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
719 lines
28 KiB
Go
719 lines
28 KiB
Go
package serverstack
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gofrs/flock"
|
|
"go.uber.org/zap"
|
|
|
|
"github.com/zzet/gortex/internal/astquery"
|
|
"github.com/zzet/gortex/internal/config"
|
|
"github.com/zzet/gortex/internal/contracts"
|
|
"github.com/zzet/gortex/internal/daemon"
|
|
"github.com/zzet/gortex/internal/embedding"
|
|
"github.com/zzet/gortex/internal/graph"
|
|
"github.com/zzet/gortex/internal/indexer"
|
|
gortexmcp "github.com/zzet/gortex/internal/mcp"
|
|
"github.com/zzet/gortex/internal/parser"
|
|
"github.com/zzet/gortex/internal/parser/languages"
|
|
"github.com/zzet/gortex/internal/platform"
|
|
"github.com/zzet/gortex/internal/query"
|
|
"github.com/zzet/gortex/internal/savings"
|
|
"github.com/zzet/gortex/internal/semantic"
|
|
"github.com/zzet/gortex/internal/semantic/goanalysis"
|
|
"github.com/zzet/gortex/internal/semantic/lsp"
|
|
"github.com/zzet/gortex/internal/semantic/scip"
|
|
"github.com/zzet/gortex/internal/semantic/tstypes"
|
|
"github.com/zzet/gortex/internal/telemetry"
|
|
)
|
|
|
|
// Lifecycle selects the backend default, whether warm-restart/snapshot
|
|
// machinery the entry point wires is appropriate, and the store-lock
|
|
// posture.
|
|
type Lifecycle int
|
|
|
|
const (
|
|
// LifecycleDaemon is the durable, long-lived daemon: sqlite default,
|
|
// cross-process store lock.
|
|
LifecycleDaemon Lifecycle = iota
|
|
// LifecycleHTTP is the daemon's HTTP surface (gortex daemon --http):
|
|
// durable, sqlite default, store lock.
|
|
LifecycleHTTP
|
|
// LifecycleOneshot is the ephemeral embedded server: memory-only,
|
|
// FileStore snapshot, no store lock.
|
|
LifecycleOneshot
|
|
)
|
|
|
|
// Writable reports whether the lifecycle owns a durable on-disk store
|
|
// (and therefore takes the cross-process store lock).
|
|
func (l Lifecycle) Writable() bool { return l == LifecycleDaemon || l == LifecycleHTTP }
|
|
|
|
// defaultBackend resolves the backend name for an empty cfg.Backend.
|
|
func (l Lifecycle) defaultBackend() string {
|
|
if l == LifecycleOneshot {
|
|
return "memory"
|
|
}
|
|
return "sqlite"
|
|
}
|
|
|
|
// SharedServerConfig carries the knobs that vary between the daemon, the
|
|
// HTTP surface, and the one-shot embedded path. The first block is the
|
|
// authoritative public surface consumers cite; the rest are
|
|
// entry-point-resolved options threaded through with the already-loaded
|
|
// config rather than re-derived here.
|
|
type SharedServerConfig struct {
|
|
Lifecycle Lifecycle // backend default + store-lock posture
|
|
Index string // workspace root the indexer/LSP/bind anchor at
|
|
Backend string // "" resolves via Lifecycle; else memory|sqlite
|
|
BackendPath string // "" => ~/.gortex/store/store.sqlite
|
|
SnapshotPath string // gob+gzip pre-load path; entry point applies it
|
|
HTTPAddr string // opts the lifecycle into the /mcp HTTP surface
|
|
Watch bool // filesystem watcher / incremental reindex
|
|
|
|
// Entry-point-resolved options (not part of the authoritative surface).
|
|
Config *config.Config // loaded .gortex.yaml (required)
|
|
Global *config.GlobalConfig // loaded ~/.gortex/config.yaml
|
|
Logger *zap.Logger
|
|
Version string
|
|
Embedder EmbedderRequest
|
|
BufferPoolMB uint64
|
|
SideStores SideStores
|
|
ScopeWorkspace string
|
|
ScopeProject string
|
|
// ActiveProject names the project the MCP server should start scoped
|
|
// to (multi-repo mode hint). Empty leaves it unset.
|
|
ActiveProject string
|
|
// SemanticMode selects the goanalysis provider mode: "callgraph"
|
|
// builds the call graph; anything else (default) type-checks.
|
|
SemanticMode string
|
|
// SavingsPath overrides the token-savings ledger database; empty
|
|
// defaults to the machine-global sidecar under the data dir
|
|
// (savings.DefaultDBPath() — the same database the `gortex savings`
|
|
// CLI reads), independent of SideStores: deriving it from a per-mode
|
|
// side-store dir would split the ledger between writer and reader.
|
|
// Tests MUST set it (with SavingsLegacyJSON) to temp paths or the
|
|
// constructor mutates the developer's real ledger. SavingsRepo
|
|
// scopes the accumulated totals (empty = workspace-global).
|
|
// SavingsLegacyJSON names the flat-file era's cumulative
|
|
// savings.json to import once — its sibling .jsonl event log rides
|
|
// along; empty uses the historical default location under the
|
|
// cache dir.
|
|
SavingsPath string
|
|
SavingsLegacyJSON string
|
|
SavingsRepo string
|
|
}
|
|
|
|
// SideStores configures where the agent-authored knowledge stores
|
|
// persist. Each (dir, repo) pair selects the sidecar DB file (dir, which
|
|
// opens <dir>/sidecar.sqlite) and the partition key (repo, hashed via
|
|
// persistence.RepoCacheKey); an empty dir OR repo yields an in-memory
|
|
// store that never flushes. The zero value therefore makes every store
|
|
// ephemeral — entry points opt into persistence explicitly, encoding
|
|
// whether the server owns one repo (per-repo keying) or many (a
|
|
// workspace-global key).
|
|
type SideStores struct {
|
|
// NotesDir / NotesRepo back the durable, repo-partitioned notes and
|
|
// development-memory stores.
|
|
NotesDir string
|
|
NotesRepo string
|
|
// FeedbackDir / FeedbackRepo back feedback, the combo tracker, and the
|
|
// frecency tracker (the implicit-signal stores).
|
|
FeedbackDir string
|
|
FeedbackRepo string
|
|
// NotebookPath is the repository-local notebook root (committed to
|
|
// git so it travels with the repo); empty leaves the notebook
|
|
// uninitialised.
|
|
NotebookPath string
|
|
}
|
|
|
|
// SharedServer bundles the constructed stack plus the teardown chain.
|
|
type SharedServer struct {
|
|
Graph graph.Store
|
|
Indexer *indexer.Indexer
|
|
MultiIndexer *indexer.MultiIndexer // nil in single-repo standalone
|
|
Engine *query.Engine
|
|
MCP *gortexmcp.Server
|
|
ConfigMgr *config.ConfigManager
|
|
Overlays *daemon.OverlayManager
|
|
// EmbedderDims is the active embedder's vector dimensionality, or 0
|
|
// when embeddings are off. The entry point's snapshot warm-start
|
|
// compares it to a snapshot's vector dims before skipping a re-embed.
|
|
EmbedderDims int
|
|
|
|
// ResolverLSPRegistry / LSPRouter are the resolve-time LSP wiring the
|
|
// entry point's warmup hooks reference; nil when semantic enrichment
|
|
// is off.
|
|
ResolverLSPRegistry *lsp.ResolverHelperRegistry
|
|
LSPRouter *lsp.Router
|
|
|
|
cleanup []func() // run LIFO by Close (backend close, savings flush).
|
|
}
|
|
|
|
// Close runs the teardown chain LIFO.
|
|
func (s *SharedServer) Close() error {
|
|
for i := len(s.cleanup) - 1; i >= 0; i-- {
|
|
s.cleanup[i]()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// telemetrySendInterval bounds how often the daemon flushes the in-memory
|
|
// usage recorder and attempts to send completed daily aggregates. Long, since
|
|
// only whole UTC days are ever sent and MaybeSend self-limits to once per day.
|
|
const telemetrySendInterval = 6 * time.Hour
|
|
|
|
// telemetryConsentTTL bounds how often the daemon recorder re-reads consent on
|
|
// the hot record path — short enough that `gortex telemetry off` stops
|
|
// recording promptly, long enough to avoid a consent-file read per tool call.
|
|
const telemetryConsentTTL = 5 * time.Second
|
|
|
|
// startTelemetrySender launches the daemon-only periodic flush-and-send loop
|
|
// and returns a stop function for the cleanup chain. It re-resolves consent
|
|
// each tick so `gortex telemetry off` stops transmission within one interval,
|
|
// and is a complete no-op while GORTEX_TELEMETRY_ENDPOINT is unset.
|
|
func startTelemetrySender(srv *gortexmcp.Server, store *telemetry.Store, dir, version string) func() {
|
|
sender := telemetry.NewSender(store, dir, version, os.Getenv)
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
go func() {
|
|
t := time.NewTicker(telemetrySendInterval)
|
|
defer t.Stop()
|
|
flushAndSend := func() {
|
|
srv.FlushTelemetry()
|
|
consent := telemetry.ResolveConsent(telemetry.LoadConsentConfig(dir), os.Getenv)
|
|
sender.MaybeSend(ctx, consent)
|
|
}
|
|
flushAndSend() // opportunistic send on startup
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-t.C:
|
|
flushAndSend()
|
|
}
|
|
}
|
|
}()
|
|
return cancel
|
|
}
|
|
|
|
// NewSharedServer builds the whole server stack — graph.Store ->
|
|
// parser.Registry -> indexer -> query.Engine -> mcp.Server plus every
|
|
// side-effect init — through one wiring shared by the daemon, the HTTP
|
|
// surface, and the one-shot embedded path. Snapshot warm-start and the
|
|
// per-repo warmup loop stay with the entry point (they orchestrate around
|
|
// the returned graph/indexer); this constructor is the dedup spine for
|
|
// the stack wiring itself.
|
|
func NewSharedServer(cfg SharedServerConfig) (*SharedServer, error) {
|
|
logger := cfg.Logger
|
|
if logger == nil {
|
|
logger = zap.NewNop()
|
|
}
|
|
conf := cfg.Config
|
|
if conf == nil {
|
|
conf = config.Default()
|
|
}
|
|
backendName := cfg.Backend
|
|
if backendName == "" {
|
|
backendName = cfg.Lifecycle.defaultBackend()
|
|
}
|
|
|
|
// Load user-defined domain-extractor rules (TOML tree-sitter patterns).
|
|
for _, pattern := range conf.RuleFiles {
|
|
matches, _ := filepath.Glob(pattern)
|
|
if len(matches) == 0 {
|
|
matches = []string{pattern}
|
|
}
|
|
for _, rp := range matches {
|
|
if n, lerr := astquery.LoadUserRulesFile(rp); lerr != nil {
|
|
logger.Warn("serverstack: failed to load domain rule file",
|
|
zap.String("file", rp), zap.Error(lerr))
|
|
} else if n > 0 {
|
|
logger.Info("serverstack: loaded domain-extractor rules",
|
|
zap.String("file", rp), zap.Int("rules", n))
|
|
}
|
|
}
|
|
}
|
|
|
|
s := &SharedServer{}
|
|
|
|
// Cross-process store lock: a writable, on-disk lifecycle
|
|
// acquires an advisory flock on store.sqlite.lock and fails fast if
|
|
// another process owns the store. SQLite's in-process writeMu +
|
|
// busy_timeout serialise in-process writers only; nothing else stops
|
|
// a second OS process opening the same file and corrupting it.
|
|
storeLockHeld := false // set true once the exclusive store flock is owned below
|
|
if cfg.Lifecycle.Writable() && isSqliteBackend(backendName) {
|
|
storePath, perr := resolveBackendPath(cfg.BackendPath, "store.sqlite")
|
|
if perr != nil {
|
|
return nil, perr
|
|
}
|
|
lock := flock.New(storePath + ".lock")
|
|
locked, lerr := lock.TryLock()
|
|
if lerr != nil {
|
|
return nil, fmt.Errorf("acquire store lock %q: %w", storePath+".lock", lerr)
|
|
}
|
|
if !locked {
|
|
hint := "the store is already owned by another gortex process"
|
|
if pid, ok := daemon.RunningPID(); ok {
|
|
hint = fmt.Sprintf("store already owned by daemon pid %d", pid)
|
|
}
|
|
return nil, fmt.Errorf("%s — stop it first (`gortex daemon stop`)", hint)
|
|
}
|
|
s.cleanup = append(s.cleanup, func() { _ = lock.Unlock() })
|
|
storeLockHeld = true
|
|
}
|
|
|
|
// allowRebuild is gated on actually holding the store lock: only then may
|
|
// the sqlite backend drop and recreate an incompatible-schema DB.
|
|
g, backendCleanup, err := OpenBackend(backendName, cfg.BackendPath, cfg.BufferPoolMB, logger, storeLockHeld)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
s.cleanup = append(s.cleanup, backendCleanup)
|
|
s.Graph = g
|
|
|
|
reg := parser.NewRegistry()
|
|
languages.RegisterAll(reg)
|
|
languages.RegisterCustomGrammars(reg, conf.Index.Grammars, logger)
|
|
languages.RegisterExtractorPlugins(reg, conf.Index.ExtractorPlugins, logger)
|
|
languages.RegisterFallbackChunkers(reg, conf.Index.FallbackChunkers, logger)
|
|
|
|
idx := indexer.New(g, reg, conf.Index, logger)
|
|
s.Indexer = idx
|
|
|
|
// Semantic enrichment (opt-in). A daemon-managed LSP router owns
|
|
// subprocess lifecycle; SCIP + goanalysis providers register eagerly;
|
|
// Manager.SetLSPRouter installs the bridge so EnrichAll can lazy-spawn
|
|
// LSPs on demand. RegisterAvailable auto-registers every known LSP
|
|
// spec whose binary resolves on PATH (per-spec / GORTEX_LSP_DISABLE
|
|
// opt-out). All lifecycles get this (reconciles the prior mcp-only
|
|
// drift where the embedded path skipped auto-registration).
|
|
var semMgr *semantic.Manager
|
|
if conf.Semantic.Enabled {
|
|
semCfg := semantic.Config{
|
|
Enabled: conf.Semantic.Enabled,
|
|
TimeoutSeconds: conf.Semantic.TimeoutSeconds,
|
|
EnrichOnWatch: conf.Semantic.EnrichOnWatch,
|
|
WatchDebounceMs: conf.Semantic.WatchDebounceMs,
|
|
RefuteUnconfirmed: conf.Semantic.RefuteUnconfirmed,
|
|
ExcludeGlobs: conf.Semantic.ExcludeGlobs,
|
|
LSPSweep: conf.Semantic.LSPSweep,
|
|
EagerLSP: eagerLSPEnabled(conf.Semantic),
|
|
}
|
|
for _, pc := range conf.Semantic.Providers {
|
|
out := semantic.ProviderConfig{
|
|
Name: pc.Name,
|
|
Languages: pc.Languages,
|
|
Command: pc.Command,
|
|
Args: pc.Args,
|
|
Env: pc.Env,
|
|
Mode: pc.Mode,
|
|
Daemon: pc.Daemon,
|
|
Priority: pc.Priority,
|
|
Enabled: pc.Enabled,
|
|
}
|
|
if pc.Connect != nil {
|
|
out.Connect = &semantic.ConnectConfig{
|
|
Network: pc.Connect.Network,
|
|
Address: pc.Connect.Address,
|
|
FallbackSpawn: pc.Connect.FallbackSpawn,
|
|
}
|
|
}
|
|
semCfg.Providers = append(semCfg.Providers, out)
|
|
}
|
|
semMgr = semantic.NewManager(semCfg, logger)
|
|
|
|
goMode := goanalysis.ModeTypeCheck
|
|
if cfg.SemanticMode == "callgraph" {
|
|
goMode = goanalysis.ModeCallGraph
|
|
}
|
|
goProvider := goanalysis.NewProvider(goMode, goTypesIncludeTests(), logger)
|
|
// The go/packages "go-types" provider owns Go enrichment when the
|
|
// toolchain is present: it type-checks the module once in-process and
|
|
// stamps every symbol's exact type plus resolves stdlib/dep calls to
|
|
// real nodes, in a fraction of the time the per-request gopls LSP pass
|
|
// takes (which additionally leaves external calls as dead stubs). As an
|
|
// eager provider it claims the "go" language slot, so the LSP router's
|
|
// gopls spec is skipped as a gap-filler and never spawns. Registration
|
|
// is gated on Available() (a `go` toolchain on PATH): without it the
|
|
// slot stays open and gopls / the tree-sitter floor serve Go instead.
|
|
if goTypesEnrichEnabled(conf.Semantic) && goProvider.Available() {
|
|
semMgr.RegisterProvider(goProvider)
|
|
}
|
|
contracts.SetBindingResolver(goProvider)
|
|
|
|
// In-process tree-sitter type resolvers — always-available
|
|
// (no external binary), supplemental (they coexist with LSP /
|
|
// SCIP providers instead of competing for the language slot).
|
|
// Disable one via a `semantic.providers` entry with
|
|
// `enabled: false` under its name (e.g. "java-types").
|
|
for _, tp := range tstypes.DefaultProviders(logger) {
|
|
semMgr.RegisterProvider(tp)
|
|
}
|
|
|
|
lspWorkspace := cfg.Index
|
|
if lspWorkspace == "" {
|
|
lspWorkspace, _ = os.Getwd()
|
|
}
|
|
lspRouter := lsp.NewRouter(lspWorkspace, logger).
|
|
WithIdleTimeout(10 * time.Minute).
|
|
WithReaperInterval(time.Minute).
|
|
WithMaxAlive(6).
|
|
WithAdditionalWorkspaceFolders(conf.Semantic.AdditionalWorkspaceFolders).
|
|
WithEnrichExcludeGlobs(conf.Semantic.ExcludeGlobs).
|
|
WithEnrichSweepMode(semCfg.LSPSweep)
|
|
semMgr.SetLSPRouter(lspRouter)
|
|
|
|
for _, pc := range semCfg.Providers {
|
|
if !pc.Enabled {
|
|
continue
|
|
}
|
|
switch {
|
|
case strings.HasPrefix(pc.Name, "scip-") && pc.Command != "":
|
|
scipProv := scip.NewProvider(pc.Command, pc.Args, pc.Languages, semCfg.TimeoutSeconds, logger)
|
|
if pc.Mode == "definitions" {
|
|
scipProv = scipProv.WithDefinitionsOnly()
|
|
}
|
|
semMgr.RegisterProvider(scipProv)
|
|
case lsp.SpecByName(pc.Name) != nil:
|
|
var connect *lsp.ConnectSpec
|
|
if pc.Connect != nil {
|
|
connect = &lsp.ConnectSpec{
|
|
Network: pc.Connect.Network,
|
|
Address: pc.Connect.Address,
|
|
FallbackSpawn: pc.Connect.FallbackSpawn,
|
|
}
|
|
}
|
|
lspRouter.RegisterSpec(lsp.SpecWithOverridesConnect(
|
|
lsp.SpecByName(pc.Name), pc.Command, pc.Args, pc.Env, connect))
|
|
case pc.Daemon:
|
|
semMgr.RegisterProvider(lsp.NewProvider(pc.Command, pc.Args, pc.Languages, pc.Daemon, pc.MaxParallel, logger))
|
|
}
|
|
}
|
|
|
|
disabled := LspDisabledSet(conf.Semantic.Providers, os.Getenv("GORTEX_LSP_DISABLE"))
|
|
var autoRegistered []string
|
|
if !disabled["__all__"] {
|
|
autoRegistered = lspRouter.RegisterAvailable(disabled)
|
|
}
|
|
|
|
idx.SetSemanticManager(semMgr)
|
|
|
|
if !IsFalsyEnv("GORTEX_LSP_RESOLVER") {
|
|
s.ResolverLSPRegistry = lsp.NewResolverHelperRegistry()
|
|
s.LSPRouter = lspRouter
|
|
idx.SetResolverLSPHelper(s.ResolverLSPRegistry)
|
|
// Single-repo standalone mode: anchor a "" prefix helper at
|
|
// the workspace the server points at. Only fires when Index is
|
|
// set (the embedded path); the daemon leaves Index empty and
|
|
// registers per-repo helpers via the OnRepoTracked hook.
|
|
if cfg.Index != "" {
|
|
if abs, aerr := filepath.Abs(cfg.Index); aerr == nil {
|
|
helper, specs := BuildResolverLSPHelperForRepo(lspRouter, abs, lsp.ResolverPoolSizeFromEnv(1), logger)
|
|
if helper != nil {
|
|
s.ResolverLSPRegistry.Register("", helper)
|
|
logger.Info("serverstack: single-repo resolve-time LSP helpers registered",
|
|
zap.Strings("specs", specs))
|
|
}
|
|
}
|
|
}
|
|
logger.Info("serverstack: resolve-time LSP hot path enabled")
|
|
} else {
|
|
logger.Info("serverstack: resolve-time LSP hot path disabled (GORTEX_LSP_RESOLVER=0)")
|
|
}
|
|
|
|
logger.Info("serverstack: semantic enrichment enabled",
|
|
zap.Int("providers", len(semCfg.Providers)),
|
|
zap.Strings("lsp_auto_registered", autoRegistered))
|
|
}
|
|
|
|
// Layer the global ~/.gortex/config.yaml `embedding:` block under the
|
|
// repo-local one before resolving the embedder, so an `embedding:` block in
|
|
// the global file is honoured while any repo-local non-zero field still
|
|
// wins. Done here (not at the LLM merge below) so it precedes ResolveEmbedder
|
|
// and leaves its flag/env precedence untouched. Warn about any unrecognised
|
|
// top-level keys — most often an `embedding:` block written in the wrong file.
|
|
embGlobal := cfg.Global
|
|
if embGlobal == nil {
|
|
var gerr error
|
|
if embGlobal, gerr = config.LoadGlobal(); gerr != nil {
|
|
// A malformed global file is otherwise silently ignored — exactly
|
|
// when its embedding/llm block would have taken effect.
|
|
logger.Warn("serverstack: global config could not be parsed — its embedding/llm block is ignored",
|
|
zap.Error(gerr))
|
|
}
|
|
}
|
|
conf.Embedding = embGlobal.MergeEmbeddingInto(conf.Embedding)
|
|
// Diagnose unknown keys in the file that was actually loaded (which may be an
|
|
// overridden path from cfg.Global), not always the default location.
|
|
globalPath := ""
|
|
if embGlobal != nil {
|
|
globalPath = embGlobal.ConfigPath()
|
|
}
|
|
if unknown := config.UnknownGlobalKeys(globalPath); len(unknown) > 0 {
|
|
logger.Warn("serverstack: ~/.gortex/config.yaml contains keys gortex does not recognize",
|
|
zap.Strings("keys", unknown),
|
|
zap.String("hint", "see docs/semantic-search.md for embedding config placement"))
|
|
}
|
|
|
|
// Embeddings: explicit flag/env > `embedding:` config > default (on,
|
|
// static GloVe).
|
|
embedder, embDesc, embReport, embErr := ResolveEmbedder(cfg.Embedder, conf)
|
|
// Probe API-backed providers up front so Dimensions() is truthful before
|
|
// we log it and — crucially — before EmbedderDims gates snapshot-vector
|
|
// reload. An APIProvider reports 0 until its first embed; without this the
|
|
// log reads dim:0 and the warm-restart vector reload rejects a
|
|
// correctly-sized cached index, re-embedding the whole graph needlessly.
|
|
// Static / local providers know their width natively and don't implement
|
|
// the prober, so they skip this. Best-effort: a probe failure (bad key /
|
|
// unreachable URL) only warns — indexing still falls back to BM25.
|
|
if embedder != nil {
|
|
if prober, ok := embedder.(interface {
|
|
ProbeDimensions(context.Context) (int, error)
|
|
}); ok {
|
|
if dim, perr := prober.ProbeDimensions(context.Background()); perr != nil {
|
|
logger.Warn("serverstack: embedding dimension probe failed — width unknown until first embed",
|
|
zap.Error(perr))
|
|
} else {
|
|
logger.Info("serverstack: embedding dimension probed", zap.Int("dim", dim))
|
|
}
|
|
}
|
|
}
|
|
// Surface every backend the local auto-selection tried. Warn per backend
|
|
// only when the outcome was actually degraded (fell to the static fallback,
|
|
// or built nothing) AND the backend could really have worked — a backend
|
|
// that is simply not compiled into this build (the onnx/gomlx stubs) is
|
|
// benign noise and stays at debug even on a degradation.
|
|
outcomeDegraded := (embedder == nil || embReport.Chosen == "static") && len(embReport.Attempts) > 0
|
|
for _, a := range embReport.Attempts {
|
|
if outcomeDegraded && !errors.Is(a.Err, embedding.ErrBackendNotCompiled) {
|
|
logger.Warn("serverstack: embedding backend unavailable — degraded to static fallback",
|
|
zap.String("backend", a.Backend), zap.Error(a.Err))
|
|
} else {
|
|
logger.Debug("serverstack: embedding backend not available",
|
|
zap.String("backend", a.Backend), zap.Error(a.Err))
|
|
}
|
|
}
|
|
switch {
|
|
case embErr != nil:
|
|
logger.Warn("serverstack: embeddings requested but unavailable", zap.Error(embErr))
|
|
case embedder != nil:
|
|
logger.Info("serverstack: embeddings enabled",
|
|
zap.String("provider", embDesc),
|
|
zap.Int("dim", embedder.Dimensions()))
|
|
default:
|
|
logger.Info("serverstack: embeddings disabled")
|
|
}
|
|
if embedder != nil {
|
|
idx.SetEmbedder(embedder)
|
|
idx.SetEmbeddingChunkOptions(EmbeddingChunkOptions(conf))
|
|
idx.SetEmbeddingMaxSymbols(conf.Embedding.MaxSymbols)
|
|
idx.SetEmbeddingAPIConcurrency(conf.Embedding.APIConcurrency)
|
|
s.EmbedderDims = embedder.Dimensions()
|
|
}
|
|
|
|
cm, err := config.NewConfigManager("")
|
|
if err != nil {
|
|
logger.Warn("serverstack: could not load global config", zap.Error(err))
|
|
}
|
|
s.ConfigMgr = cm
|
|
|
|
var mi *indexer.MultiIndexer
|
|
if cm != nil {
|
|
mi = indexer.NewMultiIndexer(g, reg, idx.Search(), cm, logger)
|
|
if embedder != nil {
|
|
mi.SetEmbedder(embedder)
|
|
mi.SetEmbeddingChunkOptions(EmbeddingChunkOptions(conf))
|
|
mi.SetEmbeddingMaxSymbols(conf.Embedding.MaxSymbols)
|
|
mi.SetEmbeddingAPIConcurrency(conf.Embedding.APIConcurrency)
|
|
}
|
|
// Propagate the semantic manager so per-repo Indexers
|
|
// created by the MultiIndexer can run LSP enrichment.
|
|
// Without this, daemon-mode enrichment produces zero
|
|
// results (enrich:0 in DEFERRED-TIMING).
|
|
if semMgr != nil {
|
|
mi.SetSemanticManager(semMgr)
|
|
}
|
|
if s.ResolverLSPRegistry != nil {
|
|
mi.SetResolverLSPHelper(s.ResolverLSPRegistry)
|
|
if s.LSPRouter != nil {
|
|
routerRef := s.LSPRouter
|
|
registryRef := s.ResolverLSPRegistry
|
|
poolSize := lsp.ResolverPoolSizeFromEnv(1)
|
|
mi.SetOnRepoTracked(func(prefix, absPath string) {
|
|
helper, _ := BuildResolverLSPHelperForRepo(routerRef, absPath, poolSize, logger)
|
|
if helper != nil {
|
|
registryRef.Register(prefix, helper)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|
|
s.MultiIndexer = mi
|
|
|
|
toolPolicyCfg := gortexmcp.ToolPolicyConfig{
|
|
Preset: conf.MCP.Tools.Preset,
|
|
Mode: conf.MCP.Tools.Mode,
|
|
Allow: conf.MCP.Tools.Allow,
|
|
Deny: conf.MCP.Tools.Deny,
|
|
}
|
|
scopeIntentDefaults := conf.Scope.IntentDefaults
|
|
multiOpts := []gortexmcp.MultiRepoOptions{{
|
|
MultiIndexer: mi,
|
|
ConfigManager: cm,
|
|
ActiveProject: cfg.ActiveProject,
|
|
ScopeWorkspace: cfg.ScopeWorkspace,
|
|
ScopeProject: cfg.ScopeProject,
|
|
ToolPolicy: &toolPolicyCfg,
|
|
ScopeIntentDefaults: &scopeIntentDefaults,
|
|
}}
|
|
|
|
eng := query.NewEngine(g)
|
|
eng.SetSearchProvider(idx.Search)
|
|
eng.ApplyRerankWeights(conf.Search.Weights)
|
|
s.Engine = eng
|
|
|
|
gortexmcp.Version = cfg.Version
|
|
srv := gortexmcp.NewServer(eng, g, idx, nil, logger, conf.Guards.Rules, multiOpts...)
|
|
srv.SetArchitecture(conf.Architecture)
|
|
srv.SetEventRules(conf.Events.Rules)
|
|
srv.SetArtifacts(conf.Artifacts)
|
|
srv.SetNamedQueries(conf.Queries)
|
|
srv.SetSearchConfig(conf.Search)
|
|
s.MCP = srv
|
|
|
|
overlays := daemon.NewOverlayManager(daemon.OverlayIdleTTLFromEnv(0))
|
|
srv.SetOverlayManager(overlays)
|
|
s.Overlays = overlays
|
|
stopOverlayJanitor := overlays.StartJanitor(0, func(dropped int) {
|
|
logger.Info("overlay janitor: swept idle sessions", zap.Int("dropped", dropped))
|
|
})
|
|
s.cleanup = append(s.cleanup, stopOverlayJanitor)
|
|
|
|
if semMgr := idx.SemanticManager(); semMgr != nil {
|
|
srv.SetSemanticManager(semMgr)
|
|
srv.SetLSPDiagnosticsBroadcasting()
|
|
}
|
|
|
|
// Opt-in usage telemetry. Every entry point records (consent-gated and
|
|
// fail-silent — nothing happens unless the user enabled it); the daemon
|
|
// additionally flushes and sends completed daily aggregates on a long
|
|
// interval, dormant until GORTEX_TELEMETRY_ENDPOINT is configured. Both the
|
|
// record path (via a TTL-cached resolver) and the send path re-resolve
|
|
// consent live, so `gortex telemetry off` stops recording within
|
|
// telemetryConsentTTL and stops transmission within one send interval —
|
|
// rather than freezing the decision at startup.
|
|
teleDir := platform.TelemetryDir()
|
|
teleStore := telemetry.NewStore(teleDir)
|
|
teleRec := telemetry.NewRecorderFunc(
|
|
telemetry.CachedConsentResolver(teleDir, telemetryConsentTTL), teleStore)
|
|
srv.SetTelemetryRecorder(teleRec)
|
|
s.cleanup = append(s.cleanup, func() { srv.FlushTelemetry() })
|
|
if cfg.Lifecycle == LifecycleDaemon {
|
|
// One daemon-session event per process start, dimensioned by backend.
|
|
// Opt-in + fail-silent: a disabled recorder drops it.
|
|
telemetry.RecordDaemonSession(teleRec, backendName)
|
|
s.cleanup = append(s.cleanup, startTelemetrySender(srv, teleStore, teleDir, cfg.Version))
|
|
}
|
|
|
|
// Side-stores. The HTTP path previously wired NONE of these; routing it
|
|
// through the shared constructor adds notes/memories/savings to it.
|
|
sideCfg := cfg.SideStores
|
|
srv.InitFeedback(sideCfg.FeedbackDir, sideCfg.FeedbackRepo)
|
|
srv.InitNotes(sideCfg.NotesDir, sideCfg.NotesRepo)
|
|
// Learned tool surface: re-promote tools this workspace promoted in
|
|
// prior sessions (demoting the ones that fell out of use). Runs after
|
|
// the NewServer register sweep, so the cold tools are already deferred.
|
|
srv.InitLearnedTools(sideCfg.NotesDir, sideCfg.NotesRepo)
|
|
srv.InitMemories(sideCfg.NotesDir, sideCfg.NotesRepo)
|
|
srv.InitSuppressions(sideCfg.NotesDir, sideCfg.NotesRepo)
|
|
srv.InitNotebook(sideCfg.NotebookPath)
|
|
srv.InitCombo(sideCfg.FeedbackDir, sideCfg.FeedbackRepo, gortexmcp.ModeAI)
|
|
srv.InitFrecency(sideCfg.FeedbackDir, sideCfg.FeedbackRepo, gortexmcp.ModeAI)
|
|
|
|
// The savings ledger is machine-global: every entry point defaults to
|
|
// the same sidecar database the `gortex savings` CLI reads. Deriving
|
|
// it from a per-mode side-store dir would split the ledger between
|
|
// writer and reader — the failure mode the flat files had.
|
|
savingsPath := cfg.SavingsPath
|
|
if savingsPath == "" {
|
|
savingsPath = savings.DefaultDBPath()
|
|
}
|
|
if savingsStore, err := savings.Open(savingsPath); err == nil {
|
|
legacyJSON := cfg.SavingsLegacyJSON
|
|
if legacyJSON == "" {
|
|
legacyJSON = savings.DefaultPath()
|
|
}
|
|
if ierr := savingsStore.ImportLegacy(legacyJSON); ierr != nil {
|
|
logger.Warn("serverstack: legacy savings import failed", zap.Error(ierr))
|
|
}
|
|
srv.InitSavings(savingsStore, cfg.SavingsRepo)
|
|
s.cleanup = append(s.cleanup, func() { _ = srv.FlushSavings() })
|
|
} else {
|
|
logger.Warn("serverstack: savings persistence disabled", zap.Error(err))
|
|
}
|
|
|
|
global := cfg.Global
|
|
if global == nil {
|
|
global, _ = config.LoadGlobal()
|
|
}
|
|
if global != nil {
|
|
srv.SetupLLM(global.MergeLLMInto(conf.LLM))
|
|
// SetupLLM constructs the LLM service lazily and attaches it; nothing
|
|
// else frees it, so a graceful shutdown would leave a loaded local
|
|
// model resident (only the idle reaper unloads at runtime). Register
|
|
// its Close in the cleanup chain — the "caller owns Close" contract
|
|
// SetLLMService documents.
|
|
if llmSvc := srv.LLMService(); llmSvc != nil {
|
|
s.cleanup = append(s.cleanup, func() { _ = llmSvc.Close() })
|
|
}
|
|
}
|
|
|
|
return s, nil
|
|
}
|
|
|
|
// goTypesEnrichEnabled reports whether the in-process go/packages "go-types"
|
|
// enrichment provider should be registered for Go. Default ON: it is both
|
|
// faster and more complete than driving gopls over LSP (see the registration
|
|
// site), and registration is separately gated on the toolchain being present.
|
|
// Precedence: GORTEX_GO_TYPES=1/0 wins, then an explicit semantic.go_types
|
|
// config value, then the on-by-default.
|
|
func goTypesEnrichEnabled(sem config.SemanticConfig) bool {
|
|
if v := os.Getenv("GORTEX_GO_TYPES"); v != "" {
|
|
return v == "1" || strings.EqualFold(v, "true")
|
|
}
|
|
if sem.GoTypes != nil {
|
|
return *sem.GoTypes
|
|
}
|
|
return true
|
|
}
|
|
|
|
// goTypesIncludeTests reports whether the go-types provider should load
|
|
// _test.go files (and each package's external test variant). Loading them
|
|
// roughly doubles the go/packages work per module, so it is OFF by default;
|
|
// GORTEX_GO_TYPES_TESTS=1 opts in to stamp test-file symbols too (LSP hover
|
|
// covers them, so the opt-in closes that coverage gap when it matters).
|
|
func goTypesIncludeTests() bool {
|
|
return os.Getenv("GORTEX_GO_TYPES_TESTS") == "1" ||
|
|
strings.EqualFold(os.Getenv("GORTEX_GO_TYPES_TESTS"), "true")
|
|
}
|
|
|
|
// eagerLSPEnabled reports whether the subprocess LSP servers run during the
|
|
// synchronous enrichment pass. Default OFF — LSP is the slowest part of a cold
|
|
// index and its net-new value over the in-process tiers (go-types for Go, the
|
|
// tree-sitter floor for every language) is narrow, so it is kept off the
|
|
// cold/warm-start path and the router lazy-spawns a server on demand instead.
|
|
// GORTEX_LSP_EAGER=1 (or semantic.eager_lsp in config) restores the eager
|
|
// behaviour.
|
|
func eagerLSPEnabled(sem config.SemanticConfig) bool {
|
|
if v := os.Getenv("GORTEX_LSP_EAGER"); v != "" {
|
|
return v == "1" || strings.EqualFold(v, "true")
|
|
}
|
|
return sem.EagerLSP
|
|
}
|