Collector milestone 4: config loader + main wiring (binary builds)

internal/config loads ~/.config/agent-watcher/collector.yaml with strict
validation (KnownFields=true, so typos fail loud), applies sensible
defaults, and expands ~/ in path fields. Either source can be omitted
but at least one must be configured.

cmd/agent-watcher is the entry point: load config, build inbox.Writer,
build configured sources, run them concurrently with a shared Emit
closure, wait for SIGINT/SIGTERM, shut down. Logs to stderr — text by
default, JSON via --json-log for journald structured fields per spec
§3.4.

SIGHUP reload is a v2 item; for now restart the systemd unit to pick
up config changes.

10 config tests passing — full happy path, defaults applied, ~/
expansion, and a table of 9 invalid configs that must all reject
(missing agent, no sources, empty webhook routes, route missing
recipient/type/template, route path without leading /, unknown
top-level field, negative poll seconds).

Binary builds clean: 10.8M single static binary on Linux/amd64.
go.mod stays at Go 1.22 to match VPS toolchain.

Total: 41 tests across 4 packages, all passing. Build clean. go vet
clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
bob-boat 2026-05-06 16:22:03 -04:00
parent ba6db7c82f
commit 2183850c03
5 changed files with 502 additions and 1 deletions

148
cmd/agent-watcher/main.go Normal file
View file

@ -0,0 +1,148 @@
// Command agent-watcher is the Collector daemon.
//
// It loads a YAML config, builds the configured sources, and runs them
// concurrently. Each source converts external events (webhook POSTs, files
// dropped into a folder) into JSONL writes against the configured inbox
// directory. Output is bit-identical to the agent-ping CLI's writes, so the
// existing UserPromptSubmit hook (and the future MCP Watcher) consume the
// stream without distinguishing source.
//
// Default config path: ~/.config/agent-watcher/collector.yaml. Override with
// --config or AGENT_WATCHER_CONFIG.
//
// Signals: SIGINT and SIGTERM trigger graceful shutdown. SIGHUP reload is a
// v2 item — for now restart the unit to pick up config changes.
package main
import (
"context"
"flag"
"fmt"
"log/slog"
"os"
"os/signal"
"path/filepath"
"sync"
"syscall"
"git.botbought.ai/foreman/agent-watcher/internal/config"
"git.botbought.ai/foreman/agent-watcher/internal/inbox"
"git.botbought.ai/foreman/agent-watcher/internal/source"
"git.botbought.ai/foreman/agent-watcher/internal/source/dropfolder"
"git.botbought.ai/foreman/agent-watcher/internal/source/webhook"
)
func main() {
var (
configPath = flag.String("config", "", "path to collector.yaml (default: $AGENT_WATCHER_CONFIG or ~/.config/agent-watcher/collector.yaml)")
jsonLog = flag.Bool("json-log", false, "emit logs as JSON (for journald structured fields)")
)
flag.Parse()
logger := newLogger(*jsonLog)
path := resolveConfigPath(*configPath)
cfg, err := config.Load(path)
if err != nil {
logger.Error("config", "err", err)
os.Exit(2)
}
logger = logger.With("agent", cfg.Agent)
logger.Info("config loaded", "path", path, "inbox_dir", cfg.InboxDir)
if err := run(cfg, logger); err != nil {
logger.Error("run", "err", err)
os.Exit(1)
}
}
func run(cfg *config.Config, logger *slog.Logger) error {
w, err := inbox.NewWriter(cfg.InboxDir)
if err != nil {
return fmt.Errorf("inbox: %w", err)
}
emit := func(recipient string, ev *inbox.Event) error {
return w.Write(recipient, ev)
}
sources, err := buildSources(cfg, logger)
if err != nil {
return err
}
if len(sources) == 0 {
return fmt.Errorf("no sources configured")
}
ctx, cancel := signalContext()
defer cancel()
var wg sync.WaitGroup
for _, src := range sources {
src := src
wg.Add(1)
go func() {
defer wg.Done()
logger.Info("source starting", "name", src.Name())
if err := src.Run(ctx, emit); err != nil && err != context.Canceled {
logger.Error("source exited", "name", src.Name(), "err", err)
} else {
logger.Info("source stopped", "name", src.Name())
}
}()
}
wg.Wait()
return nil
}
func buildSources(cfg *config.Config, logger *slog.Logger) ([]source.Source, error) {
var out []source.Source
if w := cfg.Sources.Webhook; w != nil {
s, err := webhook.New(webhook.Config{
Listen: w.Listen,
Routes: w.ToWebhookRouteMap(),
}, logger)
if err != nil {
return nil, fmt.Errorf("webhook: %w", err)
}
out = append(out, s)
}
if d := cfg.Sources.DropFolder; d != nil {
out = append(out, dropfolder.New(dropfolder.Config{
Path: d.Path,
PollFallbackSeconds: d.PollFallbackSeconds,
}, logger))
}
return out, nil
}
func signalContext() (context.Context, context.CancelFunc) {
ctx, cancel := context.WithCancel(context.Background())
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-sigCh
cancel()
}()
return ctx, cancel
}
func resolveConfigPath(flagPath string) string {
if flagPath != "" {
return flagPath
}
if env := os.Getenv("AGENT_WATCHER_CONFIG"); env != "" {
return env
}
home, _ := os.UserHomeDir()
return filepath.Join(home, ".config", "agent-watcher", "collector.yaml")
}
func newLogger(asJSON bool) *slog.Logger {
var h slog.Handler
if asJSON {
h = slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo})
} else {
h = slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo})
}
return slog.New(h)
}