Files
2026-08-11 10:40:10 +01:00

385 lines
13 KiB
Go

// Command relata runs the Relata application and its administrative commands.
//
// Subcommands:
//
// serve run the HTTP server and background workers (default)
// createadmin create the initial administrator account
// seed load the demonstration dataset
// version print version information
package main
import (
"context"
"errors"
"flag"
"fmt"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/andydixon/relata/internal/analysis"
"github.com/andydixon/relata/internal/api"
"github.com/andydixon/relata/internal/audit"
"github.com/andydixon/relata/internal/auth"
"github.com/andydixon/relata/internal/config"
"github.com/andydixon/relata/internal/domain"
"github.com/andydixon/relata/internal/embeddings"
"github.com/andydixon/relata/internal/entities"
"github.com/andydixon/relata/internal/ingest"
"github.com/andydixon/relata/internal/jobs"
"github.com/andydixon/relata/internal/observability"
"github.com/andydixon/relata/internal/relationships"
"github.com/andydixon/relata/internal/search"
"github.com/andydixon/relata/internal/storage/mongo"
"github.com/andydixon/relata/internal/storage/qdrant"
"github.com/andydixon/relata/internal/web"
)
// version is overridden at build time with -ldflags "-X main.version=…".
var version = "dev"
func main() {
if err := run(); err != nil {
fmt.Fprintf(os.Stderr, "relata: %v\n", err)
os.Exit(1)
}
}
func run() error {
cmd := "serve"
args := os.Args[1:]
if len(args) > 0 && !isFlag(args[0]) {
cmd = args[0]
args = args[1:]
}
switch cmd {
case "serve":
return cmdServe(args)
case "createadmin":
return cmdCreateAdmin(args)
case "seed":
return cmdSeed(args)
case "version":
fmt.Printf("relata %s\n", version)
return nil
case "help", "-h", "--help":
usage()
return nil
default:
usage()
return fmt.Errorf("unknown command %q", cmd)
}
}
func isFlag(s string) bool { return len(s) > 0 && s[0] == '-' }
func usage() {
fmt.Fprint(os.Stderr, `Relata — a self-organising semantic document database
Usage:
relata [command] [flags]
Commands:
serve Run the HTTP server and background workers (default)
createadmin Create the initial administrator account
seed Load the demonstration dataset
version Print the version
Run "relata <command> -h" for the flags of a specific command.
Configuration is read from the environment; see .env.example.
`)
}
func cmdServe(args []string) error {
fs := flag.NewFlagSet("serve", flag.ExitOnError)
configFile := fs.String("config", ".env", "path to a key=value configuration file (optional)")
if err := fs.Parse(args); err != nil {
return err
}
cfg, err := config.Load(*configFile)
if err != nil {
return err
}
logger := observability.NewLogger(cfg.LogLevel, cfg.LogJSON)
metrics := observability.NewMetrics()
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
ctx = observability.WithLogger(ctx, logger)
logger.Info("starting relata", "version", version, "config", cfg.Redacted())
// --- Storage ------------------------------------------------------------
store, err := mongo.Connect(ctx, cfg.Mongo, metrics)
if err != nil {
return fmt.Errorf("mongodb: %w", err)
}
defer func() {
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_ = store.Close(shutdownCtx)
}()
if err := store.EnsureIndexes(ctx); err != nil {
return fmt.Errorf("create mongodb indexes: %w", err)
}
logger.Info("mongodb ready", "database", cfg.Mongo.Database)
vectors := qdrant.New(cfg.Qdrant, metrics)
if err := vectors.Health(ctx); err != nil {
return fmt.Errorf("qdrant at %s is unreachable: %w", cfg.Qdrant.URL, err)
}
// --- AI providers -------------------------------------------------------
retry := embeddings.RetryPolicy{
MaxAttempts: cfg.Ollama.MaxRetries,
BaseDelay: cfg.Ollama.RetryBaseDelay,
MaxDelay: cfg.Ollama.RetryMaxDelay,
}
embedder := embeddings.NewOllama(embeddings.OllamaOptions{
BaseURL: cfg.Ollama.BaseURL,
Model: cfg.Ollama.EmbeddingModel,
Timeout: cfg.Ollama.EmbedTimeout,
BatchSize: cfg.Ollama.EmbedBatchSize,
Retry: retry,
ExpectedDims: cfg.Ollama.EmbeddingDims,
Metrics: metrics,
})
analyser := analysis.NewOllamaAnalyser(analysis.OllamaOptions{
BaseURL: cfg.Ollama.BaseURL,
Model: cfg.Ollama.AnalysisModel,
Timeout: cfg.Ollama.AnalysisTimeout,
Retry: retry,
MaxTokens: cfg.Ollama.MaxOutputTokens,
Metrics: metrics,
})
if err := vectors.EnsureCollection(ctx, embedder.Dimensions()); err != nil {
return fmt.Errorf("qdrant collection: %w", err)
}
logger.Info("qdrant ready", "collection", cfg.Qdrant.Collection, "dimensions", embedder.Dimensions())
// The AI providers are checked but not required: Relata's deterministic
// pipeline, search and browsing all work without them, and refusing to
// start would make a model restart an outage.
if h := embedder.Health(ctx); !h.Available {
logger.Warn("embedding model unavailable at startup; items will not be embedded until it returns",
"model", h.Model, "detail", h.Detail)
}
if cfg.Ollama.AnalysisEnabled {
if h := analyser.Health(ctx); !h.Available {
logger.Warn("analysis model unavailable at startup; extraction falls back to deterministic rules",
"model", h.Model, "detail", h.Detail)
}
}
// --- Services -----------------------------------------------------------
recorder := audit.NewRecorder(store.Audit)
analysisSvc := analysis.NewService(analyser, metrics, cfg.Ollama.AnalysisEnabled)
entitySvc := entities.NewService(store.Entities, store.Aliases, store.Claims, analysisSvc, cfg)
discoverer := relationships.NewDiscoverer(store.Items, store.Chunks, store.Entities,
store.Aliases, store.Claims, store.Relationships, vectors, embedder, analysisSvc, cfg)
ingestSvc := ingest.NewService(store.Items, store.Jobs, cfg, recorder, metrics)
searchSvc := search.NewService(store.Items, store.Chunks, store.Entities,
store.Relationships, vectors, embedder, cfg, metrics)
answerSvc := search.NewAnswerer(searchSvc, analysisSvc, store.Claims, cfg.Search.AnswerEnabled)
reviewer := relationships.NewReviewer(store.Relationships, store.Items, recorder)
authSvc := auth.NewService(store.Users, store.Sessions, cfg.Session, cfg.HTTP.SecureCookies, metrics)
tokenSvc := auth.NewTokenService(store.APITokens, store.Users, auth.TokenConfig{
DefaultTTL: cfg.Auth.OAuth.TokenDefaultTTL,
MaxTTL: cfg.Auth.OAuth.TokenMaxTTL,
})
// OAuth is optional. When it is not configured these stay nil and the
// handlers report the feature as unavailable, rather than the process
// refusing to start — the break-glass local login lives here too.
var oauthSvc *auth.OAuthService
var provisioner *auth.Provisioner
if cfg.Auth.OAuth.Enabled {
oauthSvc = auth.NewOAuthService(auth.OAuthConfig{
Enabled: true,
ProviderName: cfg.Auth.OAuth.ProviderName,
Issuer: cfg.Auth.OAuth.Issuer,
AuthorizeURL: cfg.Auth.OAuth.AuthorizeURL,
TokenURL: cfg.Auth.OAuth.TokenURL,
UserInfoURL: cfg.Auth.OAuth.UserInfoURL,
JWKSURL: cfg.Auth.OAuth.JWKSURL,
ClientID: cfg.Auth.OAuth.ClientID,
ClientSecret: cfg.Auth.OAuth.ClientSecret,
RedirectURI: cfg.RedirectURI(),
Scopes: cfg.Auth.OAuth.Scopes,
AllowedEmailDomains: cfg.Auth.OAuth.AllowedEmailDomains,
Timeout: cfg.Auth.OAuth.Timeout,
})
provisioner = auth.NewProvisioner(authSvc, store.Users, store.Workspaces)
logger.Info("federated sign-in enabled",
"provider", cfg.Auth.OAuth.ProviderName,
"redirect_uri", cfg.RedirectURI(),
"local_login_enabled", cfg.Auth.LocalLoginEnabled)
} else {
logger.Warn("federated sign-in is not configured; local accounts are the only way in",
"local_login_enabled", cfg.Auth.LocalLoginEnabled)
}
pool := jobs.NewPool(store.Jobs, cfg.Workers, metrics)
pipeline := ingest.NewPipeline(ingest.Repos{
Items: store.Items, Chunks: store.Chunks, Entities: store.Entities,
Aliases: store.Aliases, Claims: store.Claims, Rels: store.Relationships,
Jobs: store.Jobs, Runs: store.Runs, Models: store.ModelVersions,
}, vectors, embedder, analysisSvc, entitySvc, discoverer, ingestSvc, cfg, pool, recorder)
pipeline.Register(pool)
// --- HTTP ---------------------------------------------------------------
renderer, err := web.NewRenderer()
if err != nil {
return fmt.Errorf("templates: %w", err)
}
webHandlers := web.NewHandlers(web.Deps{
Cfg: cfg, Renderer: renderer, Auth: authSvc, Version: version,
OAuth: oauthSvc, Provisioner: provisioner, Tokens: tokenSvc,
Items: store.Items, Chunks: store.Chunks, Entities: store.Entities,
Aliases: store.Aliases, Claims: store.Claims, Rels: store.Relationships,
Jobs: store.Jobs, Runs: store.Runs, Models: store.ModelVersions,
Users: store.Users, Vectors: vectors,
Ingest: ingestSvc, Pipeline: pipeline, Search: searchSvc, Answer: answerSvc,
Reviewer: reviewer, Analysis: analysisSvc, Audit: recorder,
Embedder: embedder, EmbedHealth: embedder, AnalyseHealth: analyser,
})
apiHandlers := api.NewHandlers(api.Deps{
Cfg: cfg,
Items: store.Items, Chunks: store.Chunks, Entities: store.Entities,
Aliases: store.Aliases, Claims: store.Claims, Rels: store.Relationships,
Jobs: store.Jobs, Runs: store.Runs, Models: store.ModelVersions, Vectors: vectors,
Ingest: ingestSvc, Pipeline: pipeline, Search: searchSvc, Answer: answerSvc,
Reviewer: reviewer, Analysis: analysisSvc, Audit: recorder, Embedder: embedder,
Tokens: tokenSvc,
})
health := &api.HealthChecker{
Mongo: store.Ping, Vectors: vectors,
Embedder: embedder, Analyser: analyser,
Version: version, Started: time.Now(),
}
router := api.NewRouter(api.RouterConfig{
Cfg: cfg, Auth: authSvc, Tokens: tokenSvc, API: apiHandlers, Web: webHandlers,
Health: health, Metrics: metrics,
Static: http.FileServerFS(web.StaticFS()),
})
srv := &http.Server{
Addr: cfg.HTTP.Addr,
Handler: withLogger(router, logger),
ReadHeaderTimeout: 15 * time.Second,
ReadTimeout: cfg.HTTP.ReadTimeout,
WriteTimeout: cfg.HTTP.WriteTimeout,
IdleTimeout: cfg.HTTP.IdleTimeout,
}
// --- Start --------------------------------------------------------------
workerCtx, stopWorkers := context.WithCancel(ctx)
defer stopWorkers()
pool.Start(workerCtx)
// Sweep expired sessions periodically, so revoked and stale sessions do not
// accumulate even if the TTL index is unavailable.
go sweepSessions(workerCtx, authSvc, logger)
serverErr := make(chan error, 1)
go func() {
logger.Info("http server listening",
"addr", cfg.HTTP.Addr, "base_url", cfg.HTTP.BaseURL,
"external_bind_allowed", cfg.HTTP.AllowExternalBind)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
serverErr <- err
}
}()
select {
case err := <-serverErr:
return fmt.Errorf("http server: %w", err)
case <-ctx.Done():
logger.Info("shutdown signal received; draining")
}
// --- Shut down ----------------------------------------------------------
//
// The HTTP server drains first so no new work is accepted, then the workers
// are given time to finish what they hold. A job that does not finish keeps
// its lease, which expires and returns it to the queue.
shutdownCtx, cancel := context.WithTimeout(context.Background(), cfg.HTTP.ShutdownTimeout)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
logger.Warn("http server did not shut down cleanly", "error", err.Error())
}
stopWorkers()
pool.Stop(cfg.HTTP.ShutdownTimeout)
logger.Info("relata stopped")
return nil
}
// sweepSessions periodically deletes expired sessions.
func sweepSessions(ctx context.Context, a *auth.Service, logger loggerType) {
t := time.NewTicker(1 * time.Hour)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
if n, err := a.SweepExpired(ctx); err != nil {
logger.Debug("session sweep failed", "error", err.Error())
} else if n > 0 {
logger.Info("removed expired sessions", "count", n)
}
}
}
}
// withLogger attaches the application logger to every request context.
func withLogger(next http.Handler, logger loggerType) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
next.ServeHTTP(w, r.WithContext(observability.WithLogger(r.Context(), logger)))
})
}
// ensureWorkspace returns the default workspace, creating it if absent.
//
// Relata is single-workspace in practice today, but every record carries a
// workspace ID and every query filters on it, so adding more later is a matter
// of creating them rather than migrating the schema.
func ensureWorkspace(ctx context.Context, store *mongo.Store) (*domain.Workspace, error) {
const defaultSlug = "default"
if ws, err := store.Workspaces.GetBySlug(ctx, defaultSlug); err == nil {
return ws, nil
} else if !errors.Is(err, domain.ErrNotFound) {
return nil, err
}
ws := &domain.Workspace{
ID: newID(),
Name: "Default workspace",
Slug: defaultSlug,
CreatedAt: time.Now().UTC(),
}
if err := store.Workspaces.Create(ctx, ws); err != nil {
// A concurrent process may have created it first.
if existing, gerr := store.Workspaces.GetBySlug(ctx, defaultSlug); gerr == nil {
return existing, nil
}
return nil, err
}
return ws, nil
}