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:
parent
ba6db7c82f
commit
2183850c03
5 changed files with 502 additions and 1 deletions
148
cmd/agent-watcher/main.go
Normal file
148
cmd/agent-watcher/main.go
Normal 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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue