Files
Andy Dixon 522de3b026 feat(replay): record runtime identity; label compatibility; analyse first divergence
Snapshots, replays and experiments now record the runtime they ran under:
build (module version, VCS commit, modified tree, commit time, Go version,
platform), schema version, a digest of the non-secret configuration that
ignores secret and credential changes, personality version, policy digest,
model routing including per-mind overrides, prompt versions, deterministic
subsystem versions and timezone. A replay compares its identity with the
snapshot's and is an exact-candidate, a different-build (differences
listed) or incompatible (schema differs; the replay stops before running).
The first non-exact step is analysed into events only in the recording,
events only in the replay, and the components they belong to, labelled as
inferred; live model calls are named as their own source of difference.
Migration 0022 is additive; unrecorded identities are reported as such.
2026-08-20 21:11:27 +01:00

265 lines
8.5 KiB
Go

// Package config loads daemon configuration with a predictable precedence:
// built-in defaults, then an optional JSON file, then NOEMA_* environment
// variables. Runtime tunables that live in the database are handled by the
// settings store, not here.
package config
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/url"
"os"
"strconv"
"strings"
"time"
)
// Config is the fully resolved daemon configuration.
type Config struct {
HTTPAddr string `json:"http_addr"`
Env string `json:"env"` // development | production
LogLevel string `json:"log_level"`
LogFormat string `json:"log_format"` // text | json
DatabaseURL string `json:"database_url"`
MasterKey string `json:"master_key"` // base64, 32 bytes; secret
BootstrapAdminUser string `json:"bootstrap_admin_user"`
BootstrapAdminPassword string `json:"bootstrap_admin_password"` // secret
ResearchDir string `json:"research_dir"`
MaxWorkers int `json:"max_workers"`
MaxConcurrentLLM int `json:"max_concurrent_llm"`
DailyTokenBudget int64 `json:"daily_token_budget"`
SessionIdleTTL time.Duration `json:"session_idle_ttl"`
SessionAbsoluteTTL time.Duration `json:"session_absolute_ttl"`
MaxRequestBody int64 `json:"max_request_body"`
}
// Defaults returns the built-in configuration.
func Defaults() Config {
return Config{
HTTPAddr: "127.0.0.1:8080",
Env: "development",
LogLevel: "info",
LogFormat: "text",
BootstrapAdminUser: "admin",
ResearchDir: "./research",
MaxWorkers: 8,
MaxConcurrentLLM: 4,
DailyTokenBudget: 2_000_000,
SessionIdleTTL: 12 * time.Hour,
SessionAbsoluteTTL: 7 * 24 * time.Hour,
MaxRequestBody: 1 << 20,
}
}
// Load resolves configuration from defaults, the optional file at path, and
// the environment provided by lookup (normally os.LookupEnv).
func Load(path string, lookup func(string) (string, bool)) (Config, error) {
c := Defaults()
if path != "" {
b, err := os.ReadFile(path) // #nosec G304 -- operator-supplied config path from the command line
if err != nil {
return c, fmt.Errorf("config: read %s: %w", path, err)
}
dec := json.NewDecoder(strings.NewReader(string(b)))
dec.DisallowUnknownFields()
var f fileConfig
if err := dec.Decode(&f); err != nil {
return c, fmt.Errorf("config: parse %s: %w", path, err)
}
f.apply(&c)
}
if err := c.applyEnv(lookup); err != nil {
return c, err
}
return c, c.Validate()
}
// fileConfig mirrors Config but with durations as strings so JSON stays readable.
type fileConfig struct {
HTTPAddr *string `json:"http_addr"`
Env *string `json:"env"`
LogLevel *string `json:"log_level"`
LogFormat *string `json:"log_format"`
DatabaseURL *string `json:"database_url"`
MasterKey *string `json:"master_key"`
BootstrapAdminUser *string `json:"bootstrap_admin_user"`
BootstrapAdminPassword *string `json:"bootstrap_admin_password"`
ResearchDir *string `json:"research_dir"`
MaxWorkers *int `json:"max_workers"`
MaxConcurrentLLM *int `json:"max_concurrent_llm"`
DailyTokenBudget *int64 `json:"daily_token_budget"`
SessionIdleTTL *string `json:"session_idle_ttl"`
SessionAbsoluteTTL *string `json:"session_absolute_ttl"`
MaxRequestBody *int64 `json:"max_request_body"`
}
func (f fileConfig) apply(c *Config) {
setS := func(dst *string, src *string) {
if src != nil {
*dst = *src
}
}
setS(&c.HTTPAddr, f.HTTPAddr)
setS(&c.Env, f.Env)
setS(&c.LogLevel, f.LogLevel)
setS(&c.LogFormat, f.LogFormat)
setS(&c.DatabaseURL, f.DatabaseURL)
setS(&c.MasterKey, f.MasterKey)
setS(&c.BootstrapAdminUser, f.BootstrapAdminUser)
setS(&c.BootstrapAdminPassword, f.BootstrapAdminPassword)
setS(&c.ResearchDir, f.ResearchDir)
if f.MaxWorkers != nil {
c.MaxWorkers = *f.MaxWorkers
}
if f.MaxConcurrentLLM != nil {
c.MaxConcurrentLLM = *f.MaxConcurrentLLM
}
if f.DailyTokenBudget != nil {
c.DailyTokenBudget = *f.DailyTokenBudget
}
if f.MaxRequestBody != nil {
c.MaxRequestBody = *f.MaxRequestBody
}
if f.SessionIdleTTL != nil {
if d, err := time.ParseDuration(*f.SessionIdleTTL); err == nil {
c.SessionIdleTTL = d
}
}
if f.SessionAbsoluteTTL != nil {
if d, err := time.ParseDuration(*f.SessionAbsoluteTTL); err == nil {
c.SessionAbsoluteTTL = d
}
}
}
func (c *Config) applyEnv(lookup func(string) (string, bool)) error {
str := func(key string, dst *string) {
if v, ok := lookup("NOEMA_" + key); ok {
*dst = v
}
}
var errs []error
integer := func(key string, dst *int) {
if v, ok := lookup("NOEMA_" + key); ok {
n, err := strconv.Atoi(v)
if err != nil {
errs = append(errs, fmt.Errorf("NOEMA_%s: %w", key, err))
return
}
*dst = n
}
}
integer64 := func(key string, dst *int64) {
if v, ok := lookup("NOEMA_" + key); ok {
n, err := strconv.ParseInt(v, 10, 64)
if err != nil {
errs = append(errs, fmt.Errorf("NOEMA_%s: %w", key, err))
return
}
*dst = n
}
}
dur := func(key string, dst *time.Duration) {
if v, ok := lookup("NOEMA_" + key); ok {
d, err := time.ParseDuration(v)
if err != nil {
errs = append(errs, fmt.Errorf("NOEMA_%s: %w", key, err))
return
}
*dst = d
}
}
str("HTTP_ADDR", &c.HTTPAddr)
str("ENV", &c.Env)
str("LOG_LEVEL", &c.LogLevel)
str("LOG_FORMAT", &c.LogFormat)
str("DATABASE_URL", &c.DatabaseURL)
str("MASTER_KEY", &c.MasterKey)
str("BOOTSTRAP_ADMIN_USER", &c.BootstrapAdminUser)
str("BOOTSTRAP_ADMIN_PASSWORD", &c.BootstrapAdminPassword)
str("RESEARCH_DIR", &c.ResearchDir)
integer("MAX_WORKERS", &c.MaxWorkers)
integer("MAX_CONCURRENT_LLM", &c.MaxConcurrentLLM)
integer64("DAILY_TOKEN_BUDGET", &c.DailyTokenBudget)
integer64("MAX_REQUEST_BODY", &c.MaxRequestBody)
dur("SESSION_IDLE_TTL", &c.SessionIdleTTL)
dur("SESSION_ABSOLUTE_TTL", &c.SessionAbsoluteTTL)
return errors.Join(errs...)
}
// Validate checks invariants that would otherwise fail later and less clearly.
func (c Config) Validate() error {
var errs []error
if c.Env != "development" && c.Env != "production" {
errs = append(errs, fmt.Errorf("env must be development or production, got %q", c.Env))
}
switch c.LogLevel {
case "debug", "info", "warn", "error":
default:
errs = append(errs, fmt.Errorf("log_level %q invalid", c.LogLevel))
}
if c.LogFormat != "text" && c.LogFormat != "json" {
errs = append(errs, fmt.Errorf("log_format %q invalid", c.LogFormat))
}
if c.DatabaseURL == "" {
errs = append(errs, errors.New("database_url is required"))
} else if _, err := url.Parse(c.DatabaseURL); err != nil {
errs = append(errs, fmt.Errorf("database_url: %w", err))
}
if c.MaxWorkers < 1 || c.MaxConcurrentLLM < 1 {
errs = append(errs, errors.New("max_workers and max_concurrent_llm must be >= 1"))
}
if c.SessionIdleTTL <= 0 || c.SessionAbsoluteTTL < c.SessionIdleTTL {
errs = append(errs, errors.New("session ttls must be positive and absolute >= idle"))
}
if c.MaxRequestBody < 4096 {
errs = append(errs, errors.New("max_request_body too small"))
}
return errors.Join(errs...)
}
// Production reports whether secure-by-default production behaviour applies.
func (c Config) Production() bool { return c.Env == "production" }
// Redacted returns a copy safe for logging: secrets and DSN passwords removed.
func (c Config) Redacted() Config {
r := c
if r.MasterKey != "" {
r.MasterKey = "[set]"
}
if r.BootstrapAdminPassword != "" {
r.BootstrapAdminPassword = "[set]"
}
if u, err := url.Parse(r.DatabaseURL); err == nil && u.User != nil {
if _, has := u.User.Password(); has {
u.User = url.UserPassword(u.User.Username(), "xxxxx")
r.DatabaseURL = u.String()
}
}
return r
}
// Digest is a stable hash of the non-secret configuration: it changes when
// the daemon would behave differently and stays the same when only secrets
// or credentials rotate. Used to explain replay differences.
func (c Config) Digest() string {
public := struct {
HTTPAddr, Env, LogLevel, LogFormat, ResearchDir string
MaxWorkers, MaxConcurrentLLM int
DailyTokenBudget, MaxRequestBody int64
SessionIdleTTL, SessionAbsoluteTTL time.Duration
HasMasterKey bool
}{c.HTTPAddr, c.Env, c.LogLevel, c.LogFormat, c.ResearchDir, c.MaxWorkers, c.MaxConcurrentLLM, c.DailyTokenBudget, c.MaxRequestBody, c.SessionIdleTTL, c.SessionAbsoluteTTL, c.MasterKey != ""}
b, _ := json.Marshal(public)
sum := sha256.Sum256(b)
return "sha256:" + hex.EncodeToString(sum[:8])
}