165 lines
5.6 KiB
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 (the SQLite DB) 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
|
|
}
|