agent-orchestrator/backend/internal/config/config.go

165 lines
5.6 KiB
Go

// Package config loads the daemon's runtime configuration. The HTTP daemon is
// a loopback-only sidecar: it binds 127.0.0.1, takes no public traffic, and
// reads everything it needs from the environment with sane defaults so it can
// boot with zero configuration in development.
package config
import (
"fmt"
"net"
"os"
"path/filepath"
"strconv"
"time"
)
const (
// LoopbackHost is the only host the daemon ever binds. There is deliberately
// no AO_HOST env var: the daemon has no auth/CORS/TLS and a stray
// AO_HOST=0.0.0.0 would turn it into a public no-auth service. The legacy
// TS server bound all-interfaces by accident and docs/CROSS_PLATFORM.md
// already calls that out as a bug; the Go rewrite fixes it by removing the
// knob entirely. If a non-default loopback (e.g. ::1, 127.0.0.2) is ever
// needed, add it back with an IsLoopback() validator — not a raw env read.
LoopbackHost = "127.0.0.1"
// DefaultPort is the single port the whole surface (REST, SSE, WS, static)
// is served from. Single-port keeps it same-origin: no CORS, one lifecycle.
DefaultPort = 3001
// DefaultRequestTimeout bounds a single request. Long-lived surfaces (SSE,
// WS) are mounted outside this timeout; it guards the REST surface only.
DefaultRequestTimeout = 60 * time.Second
// DefaultShutdownTimeout is the hard cap on graceful shutdown. After this
// the process exits even if connections are still draining.
DefaultShutdownTimeout = 10 * time.Second
)
// Config is the fully-resolved daemon configuration. It is immutable once
// built by Load.
type Config struct {
// Host is the bind address. Always loopback — see LoopbackHost.
Host string
// Port is the TCP port to bind. The daemon fails fast if it is taken.
Port int
// RequestTimeout bounds REST request handling.
RequestTimeout time.Duration
// ShutdownTimeout is the hard graceful-shutdown deadline.
ShutdownTimeout time.Duration
// RunFilePath is where the PID + port handshake file (running.json) is
// written so the Electron supervisor can discover and reap the daemon.
RunFilePath string
// DataDir is the directory holding durable state (the SQLite database and
// the CDC JSONL log). It is created on first use by the storage layer.
DataDir string
}
// Addr returns the host:port the HTTP server binds. It uses net.JoinHostPort so
// the result is correct for IPv6 literals as well as IPv4 / hostnames.
func (c Config) Addr() string {
return net.JoinHostPort(c.Host, strconv.Itoa(c.Port))
}
// Load resolves configuration from the environment, applying defaults. It
// returns an error only for values that are present but malformed (e.g. a
// non-numeric AO_PORT); missing values fall back to defaults.
//
// Recognised variables:
//
// AO_PORT bind port (default 3001)
// AO_REQUEST_TIMEOUT per-request timeout (Go duration > 0, default 60s)
// AO_SHUTDOWN_TIMEOUT shutdown deadline (Go duration > 0, default 10s)
// AO_RUN_FILE running.json path (default <state-dir>/running.json)
// AO_DATA_DIR durable state dir (default <state-dir>/data)
//
// The bind host is not configurable: the daemon is loopback-only by design.
func Load() (Config, error) {
cfg := Config{
Host: LoopbackHost,
Port: DefaultPort,
RequestTimeout: DefaultRequestTimeout,
ShutdownTimeout: DefaultShutdownTimeout,
}
if raw := os.Getenv("AO_PORT"); raw != "" {
port, err := strconv.Atoi(raw)
if err != nil {
return Config{}, fmt.Errorf("invalid AO_PORT %q: %w", raw, err)
}
if port < 1 || port > 65535 {
return Config{}, fmt.Errorf("invalid AO_PORT %d: out of range 1-65535", port)
}
cfg.Port = port
}
if raw := os.Getenv("AO_REQUEST_TIMEOUT"); raw != "" {
d, err := parsePositiveDuration("AO_REQUEST_TIMEOUT", raw)
if err != nil {
return Config{}, err
}
cfg.RequestTimeout = d
}
if raw := os.Getenv("AO_SHUTDOWN_TIMEOUT"); raw != "" {
d, err := parsePositiveDuration("AO_SHUTDOWN_TIMEOUT", raw)
if err != nil {
return Config{}, err
}
cfg.ShutdownTimeout = d
}
runFile, err := resolveRunFilePath()
if err != nil {
return Config{}, err
}
cfg.RunFilePath = runFile
dataDir, err := resolveDataDir()
if err != nil {
return Config{}, err
}
cfg.DataDir = dataDir
return cfg, nil
}
// parsePositiveDuration rejects zero and negative durations: a zero
// RequestTimeout would expire every request instantly, and a non-positive
// ShutdownTimeout would defeat graceful shutdown.
func parsePositiveDuration(name, raw string) (time.Duration, error) {
d, err := time.ParseDuration(raw)
if err != nil {
return 0, fmt.Errorf("invalid %s %q: %w", name, raw, err)
}
if d <= 0 {
return 0, fmt.Errorf("invalid %s %q: must be > 0", name, raw)
}
return d, nil
}
// resolveRunFilePath picks where running.json lives. An explicit AO_RUN_FILE
// wins; otherwise it sits under the per-user state directory so multiple repos
// share one supervisor handshake location.
func resolveRunFilePath() (string, error) {
if p, ok := os.LookupEnv("AO_RUN_FILE"); ok && p != "" {
return p, nil
}
dir, err := os.UserConfigDir()
if err != nil {
return "", fmt.Errorf("resolve state dir: %w", err)
}
return filepath.Join(dir, "agent-orchestrator", "running.json"), nil
}
// resolveDataDir picks where durable state (SQLite DB, CDC JSONL) lives. An
// explicit AO_DATA_DIR wins; otherwise it sits under the per-user state
// directory alongside running.json.
func resolveDataDir() (string, error) {
if p, ok := os.LookupEnv("AO_DATA_DIR"); ok && p != "" {
return p, nil
}
dir, err := os.UserConfigDir()
if err != nil {
return "", fmt.Errorf("resolve state dir: %w", err)
}
return filepath.Join(dir, "agent-orchestrator", "data"), nil
}