Skip to content

Commit 19584ee

Browse files
wesmclaude
andauthored
Add config hot-reload for daemon (#85) (#92)
## Summary - Watch `~/.roborev/config.toml` for changes and automatically reload configuration without requiring a daemon restart - Hot-reloadable settings: `default_agent`, `job_timeout`, `allow_unsafe_agents`, `anthropic_api_key`, `review_context_count` - Settings requiring restart: `server_addr`, `max_workers`, `[sync]` section - Show "Config reloaded" flash notification in TUI for 5 seconds when config changes are applied - Add `config_reloaded_at` timestamp to `/api/status` endpoint ## Implementation Details - Uses fsnotify for cross-platform file watching (Linux, macOS, Windows) - Handles atomic saves via rename (vim and similar editors) - `ConfigGetter` interface for thread-safe config access with snapshot-per-job to prevent mixed settings - Resource cleanup on `Start()` failure - `sync.Once` guard on `Stop()` to prevent double-close panic ## Test plan - [x] `TestConfigWatcher_ReloadOnFileChange` - verifies config reload on file change - [x] `TestConfigWatcher_HotReloadableSettingsTakeEffect` - verifies `review_context_count`, `job_timeout_minutes` update - [x] `TestConfigWatcher_InvalidConfigDoesNotCrash` - verifies graceful handling of invalid TOML - [x] `TestConfigWatcher_DoubleStopSafe` - verifies multiple Stop() calls don't panic - [x] `TestTUIConfigReloadFlash` - verifies flash behavior on config reload - [x] `TestHandleStatus` - verifies `config_reloaded_at` and `max_workers` in status Closes #85 🤖 Generated with [Claude Code](https://claude.ai/code) Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 06b2ce3 commit 19584ee

File tree

16 files changed

+806
-78
lines changed

16 files changed

+806
-78
lines changed

cmd/roborev/main.go

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -527,8 +527,12 @@ func daemonRunCmd() *cobra.Command {
527527
}
528528
}
529529

530+
// Create context for config watcher
531+
ctx, cancel := context.WithCancel(context.Background())
532+
defer cancel()
533+
530534
// Create and start server
531-
server := daemon.NewServer(db, cfg)
535+
server := daemon.NewServer(db, cfg, configPath)
532536
if syncWorker != nil {
533537
server.SetSyncWorker(syncWorker)
534538
}
@@ -544,6 +548,7 @@ func daemonRunCmd() *cobra.Command {
544548
go func() {
545549
sig := <-sigCh
546550
log.Printf("Received signal %v, shutting down...", sig)
551+
cancel() // Cancel context to stop config watcher
547552
if syncWorker != nil {
548553
// Final push before shutdown to ensure local changes are synced
549554
if err := syncWorker.FinalPush(); err != nil {
@@ -558,7 +563,7 @@ func daemonRunCmd() *cobra.Command {
558563
}()
559564

560565
// Start server (blocks until shutdown)
561-
return server.Start()
566+
return server.Start(ctx)
562567
},
563568
}
564569

cmd/roborev/tui.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,10 @@ type tuiModel struct {
124124
flashMessage string
125125
flashExpiresAt time.Time
126126
flashView tuiView // View where flash was triggered (only show in same view)
127+
128+
// Track config reload notifications
129+
lastConfigReloadedAt string // Last known ConfigReloadedAt from daemon status
130+
statusFetchedOnce bool // True after first successful status fetch (for flash logic)
127131
pendingReviewAddressed map[int64]pendingState // review ID -> pending state (for reviews without jobs)
128132
addressedSeq uint64 // monotonic counter for request sequencing
129133
}
@@ -1689,6 +1693,15 @@ func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
16891693
if m.status.Version != "" {
16901694
m.daemonVersion = m.status.Version
16911695
}
1696+
// Show flash notification when config is reloaded
1697+
// Only flash if: we've fetched status before AND the reload timestamp changed
1698+
if m.statusFetchedOnce && m.status.ConfigReloadedAt != m.lastConfigReloadedAt {
1699+
m.flashMessage = "Config reloaded"
1700+
m.flashExpiresAt = time.Now().Add(5 * time.Second)
1701+
m.flashView = m.currentView
1702+
}
1703+
m.lastConfigReloadedAt = m.status.ConfigReloadedAt
1704+
m.statusFetchedOnce = true
16921705

16931706
case tuiUpdateCheckMsg:
16941707
m.updateAvailable = msg.version

cmd/roborev/tui_test.go

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5417,3 +5417,90 @@ func TestTUIFetchReviewAndCopyClipboardFailure(t *testing.T) {
54175417
t.Errorf("Expected clipboard error message, got %q", result.err.Error())
54185418
}
54195419
}
5420+
5421+
func TestTUIConfigReloadFlash(t *testing.T) {
5422+
m := newTuiModel("http://localhost:7373")
5423+
5424+
t.Run("no flash on first status fetch", func(t *testing.T) {
5425+
// First status fetch with a ConfigReloadedAt should NOT flash
5426+
status1 := tuiStatusMsg(storage.DaemonStatus{
5427+
Version: "1.0.0",
5428+
ConfigReloadedAt: "2026-01-23T10:00:00Z",
5429+
})
5430+
5431+
updated, _ := m.Update(status1)
5432+
m2 := updated.(tuiModel)
5433+
5434+
if m2.flashMessage != "" {
5435+
t.Errorf("Expected no flash on first fetch, got %q", m2.flashMessage)
5436+
}
5437+
if !m2.statusFetchedOnce {
5438+
t.Error("Expected statusFetchedOnce to be true after first fetch")
5439+
}
5440+
if m2.lastConfigReloadedAt != "2026-01-23T10:00:00Z" {
5441+
t.Errorf("Expected lastConfigReloadedAt to be set, got %q", m2.lastConfigReloadedAt)
5442+
}
5443+
})
5444+
5445+
t.Run("flash on config reload after first fetch", func(t *testing.T) {
5446+
// Start with a model that has already fetched status once
5447+
m := newTuiModel("http://localhost:7373")
5448+
m.statusFetchedOnce = true
5449+
m.lastConfigReloadedAt = "2026-01-23T10:00:00Z"
5450+
5451+
// Second status with different ConfigReloadedAt should flash
5452+
status2 := tuiStatusMsg(storage.DaemonStatus{
5453+
Version: "1.0.0",
5454+
ConfigReloadedAt: "2026-01-23T10:05:00Z",
5455+
})
5456+
5457+
updated, _ := m.Update(status2)
5458+
m2 := updated.(tuiModel)
5459+
5460+
if m2.flashMessage != "Config reloaded" {
5461+
t.Errorf("Expected flash 'Config reloaded', got %q", m2.flashMessage)
5462+
}
5463+
if m2.lastConfigReloadedAt != "2026-01-23T10:05:00Z" {
5464+
t.Errorf("Expected lastConfigReloadedAt updated, got %q", m2.lastConfigReloadedAt)
5465+
}
5466+
})
5467+
5468+
t.Run("flash when ConfigReloadedAt changes from empty to non-empty", func(t *testing.T) {
5469+
// Model has fetched status once but daemon hadn't reloaded yet
5470+
m := newTuiModel("http://localhost:7373")
5471+
m.statusFetchedOnce = true
5472+
m.lastConfigReloadedAt = "" // No reload had occurred
5473+
5474+
// Now config is reloaded
5475+
status := tuiStatusMsg(storage.DaemonStatus{
5476+
Version: "1.0.0",
5477+
ConfigReloadedAt: "2026-01-23T10:00:00Z",
5478+
})
5479+
5480+
updated, _ := m.Update(status)
5481+
m2 := updated.(tuiModel)
5482+
5483+
if m2.flashMessage != "Config reloaded" {
5484+
t.Errorf("Expected flash when ConfigReloadedAt goes from empty to set, got %q", m2.flashMessage)
5485+
}
5486+
})
5487+
5488+
t.Run("no flash when ConfigReloadedAt unchanged", func(t *testing.T) {
5489+
m := newTuiModel("http://localhost:7373")
5490+
m.statusFetchedOnce = true
5491+
m.lastConfigReloadedAt = "2026-01-23T10:00:00Z"
5492+
5493+
// Same timestamp
5494+
status := tuiStatusMsg(storage.DaemonStatus{
5495+
Version: "1.0.0",
5496+
ConfigReloadedAt: "2026-01-23T10:00:00Z",
5497+
})
5498+
5499+
updated, _ := m.Update(status)
5500+
m2 := updated.(tuiModel)
5501+
5502+
if m2.flashMessage != "" {
5503+
t.Errorf("Expected no flash when timestamp unchanged, got %q", m2.flashMessage)
5504+
}
5505+
})
5506+
}

e2e_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ func TestE2EEnqueueAndReview(t *testing.T) {
3535

3636
// Create a mock server
3737
cfg := config.DefaultConfig()
38-
server := daemon.NewServer(db, cfg)
38+
server := daemon.NewServer(db, cfg, "")
3939

4040
// Create test HTTP server
4141
mux := http.NewServeMux()

flake.nix

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919

2020
src = ./.;
2121

22-
vendorHash = "sha256-cK2kLRu6riVReSflP//YaKOZc2VBCfAjVmKd4AsXdYQ=";
22+
vendorHash = "sha256-OQI9CbvazUzdbHTuUbizVK5UMq1OEbi2OJ7sL4bKJ44=";
2323

2424
subPackages = [ "cmd/roborev" ];
2525

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ require (
2222
github.com/charmbracelet/x/term v0.2.1 // indirect
2323
github.com/dustin/go-humanize v1.0.1 // indirect
2424
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
25+
github.com/fsnotify/fsnotify v1.9.0 // indirect
2526
github.com/inconshreveable/mousetrap v1.1.0 // indirect
2627
github.com/jackc/pgpassfile v1.0.0 // indirect
2728
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
2424
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
2525
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
2626
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
27+
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
28+
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
2729
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
2830
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
2931
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=

internal/daemon/config_watcher.go

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
package daemon
2+
3+
import (
4+
"context"
5+
"log"
6+
"path/filepath"
7+
"sync"
8+
"time"
9+
10+
"github.com/fsnotify/fsnotify"
11+
"github.com/roborev-dev/roborev/internal/agent"
12+
"github.com/roborev-dev/roborev/internal/config"
13+
)
14+
15+
// ConfigGetter provides access to the current config
16+
type ConfigGetter interface {
17+
Config() *config.Config
18+
}
19+
20+
// StaticConfig wraps a config for use without hot-reloading (e.g., in tests)
21+
type StaticConfig struct {
22+
cfg *config.Config
23+
}
24+
25+
// NewStaticConfig creates a ConfigGetter that always returns the same config
26+
func NewStaticConfig(cfg *config.Config) *StaticConfig {
27+
return &StaticConfig{cfg: cfg}
28+
}
29+
30+
// Config returns the static config
31+
func (sc *StaticConfig) Config() *config.Config {
32+
return sc.cfg
33+
}
34+
35+
// ConfigWatcher watches config.toml for changes and reloads configuration.
36+
//
37+
// Hot-reloadable settings take effect immediately: default_agent, job_timeout,
38+
// allow_unsafe_agents, anthropic_api_key, review_context_count.
39+
//
40+
// Settings requiring restart: server_addr, max_workers, [sync] section.
41+
// These are read at startup and the running values are preserved even if the
42+
// config file changes. CLI flag overrides (--addr, --workers) only apply to
43+
// restart-required settings, so they remain in effect for the daemon's lifetime.
44+
// The config object may show file values after reload, but the actual running
45+
// server address and worker pool size are fixed at startup.
46+
type ConfigWatcher struct {
47+
configPath string
48+
cfg *config.Config
49+
cfgMu sync.RWMutex
50+
broadcaster Broadcaster
51+
watcher *fsnotify.Watcher
52+
stopCh chan struct{}
53+
stopOnce sync.Once
54+
lastReloadedAt time.Time // Time of last successful config reload
55+
}
56+
57+
// NewConfigWatcher creates a new config watcher
58+
func NewConfigWatcher(configPath string, cfg *config.Config, broadcaster Broadcaster) *ConfigWatcher {
59+
return &ConfigWatcher{
60+
configPath: configPath,
61+
cfg: cfg,
62+
broadcaster: broadcaster,
63+
stopCh: make(chan struct{}),
64+
}
65+
}
66+
67+
// Start begins watching the config file for changes
68+
func (cw *ConfigWatcher) Start(ctx context.Context) error {
69+
// Skip watching if no config path provided (e.g., in tests)
70+
if cw.configPath == "" {
71+
return nil
72+
}
73+
74+
watcher, err := fsnotify.NewWatcher()
75+
if err != nil {
76+
return err
77+
}
78+
cw.watcher = watcher
79+
80+
// Watch the directory containing the config file, not the file itself.
81+
// This handles editors that do atomic writes (delete + create).
82+
configDir := filepath.Dir(cw.configPath)
83+
configFile := filepath.Base(cw.configPath)
84+
85+
if err := watcher.Add(configDir); err != nil {
86+
watcher.Close()
87+
return err
88+
}
89+
90+
go cw.watchLoop(ctx, configFile)
91+
return nil
92+
}
93+
94+
// Stop stops the config watcher. Safe to call multiple times.
95+
func (cw *ConfigWatcher) Stop() {
96+
cw.stopOnce.Do(func() {
97+
close(cw.stopCh)
98+
if cw.watcher != nil {
99+
cw.watcher.Close()
100+
}
101+
})
102+
}
103+
104+
// Config returns the current config with read lock
105+
func (cw *ConfigWatcher) Config() *config.Config {
106+
cw.cfgMu.RLock()
107+
defer cw.cfgMu.RUnlock()
108+
return cw.cfg
109+
}
110+
111+
// LastReloadedAt returns the time of the last successful config reload
112+
func (cw *ConfigWatcher) LastReloadedAt() time.Time {
113+
cw.cfgMu.RLock()
114+
defer cw.cfgMu.RUnlock()
115+
return cw.lastReloadedAt
116+
}
117+
118+
func (cw *ConfigWatcher) watchLoop(ctx context.Context, configFile string) {
119+
// Debounce timer to handle rapid file changes
120+
var debounceTimer *time.Timer
121+
debounceDelay := 200 * time.Millisecond
122+
123+
for {
124+
select {
125+
case <-ctx.Done():
126+
return
127+
case <-cw.stopCh:
128+
return
129+
case event, ok := <-cw.watcher.Events:
130+
if !ok {
131+
return
132+
}
133+
134+
// Only react to changes to our config file
135+
if filepath.Base(event.Name) != configFile {
136+
continue
137+
}
138+
139+
// React to write, create, or rename events
140+
// Rename is needed for editors that do atomic saves via rename (e.g., vim)
141+
if event.Op&(fsnotify.Write|fsnotify.Create|fsnotify.Rename) == 0 {
142+
continue
143+
}
144+
145+
// Debounce: reset timer on each event
146+
if debounceTimer != nil {
147+
debounceTimer.Stop()
148+
}
149+
debounceTimer = time.AfterFunc(debounceDelay, func() {
150+
cw.reloadConfig()
151+
})
152+
153+
case err, ok := <-cw.watcher.Errors:
154+
if !ok {
155+
return
156+
}
157+
log.Printf("Config watcher error: %v", err)
158+
}
159+
}
160+
}
161+
162+
func (cw *ConfigWatcher) reloadConfig() {
163+
newCfg, err := config.LoadGlobalFrom(cw.configPath)
164+
if err != nil {
165+
log.Printf("Failed to reload config: %v", err)
166+
return
167+
}
168+
169+
cw.cfgMu.Lock()
170+
oldCfg := cw.cfg
171+
cw.cfg = newCfg
172+
cw.lastReloadedAt = time.Now()
173+
cw.cfgMu.Unlock()
174+
175+
// Update global agent settings
176+
agent.SetAllowUnsafeAgents(newCfg.AllowUnsafeAgents != nil && *newCfg.AllowUnsafeAgents)
177+
agent.SetAnthropicAPIKey(newCfg.AnthropicAPIKey)
178+
179+
// Log what changed (for debugging)
180+
logConfigChanges(oldCfg, newCfg)
181+
182+
// Broadcast config reloaded event to notify connected clients
183+
cw.broadcaster.Broadcast(Event{
184+
Type: "config.reloaded",
185+
TS: time.Now(),
186+
})
187+
188+
log.Printf("Config reloaded successfully")
189+
}
190+
191+
func logConfigChanges(old, new *config.Config) {
192+
if old.DefaultAgent != new.DefaultAgent {
193+
log.Printf("Config change: default_agent %q -> %q", old.DefaultAgent, new.DefaultAgent)
194+
}
195+
if old.ReviewContextCount != new.ReviewContextCount {
196+
log.Printf("Config change: review_context_count %d -> %d", old.ReviewContextCount, new.ReviewContextCount)
197+
}
198+
if old.JobTimeoutMinutes != new.JobTimeoutMinutes {
199+
log.Printf("Config change: job_timeout_minutes %d -> %d", old.JobTimeoutMinutes, new.JobTimeoutMinutes)
200+
}
201+
oldUnsafe := old.AllowUnsafeAgents != nil && *old.AllowUnsafeAgents
202+
newUnsafe := new.AllowUnsafeAgents != nil && *new.AllowUnsafeAgents
203+
if oldUnsafe != newUnsafe {
204+
log.Printf("Config change: allow_unsafe_agents %v -> %v", oldUnsafe, newUnsafe)
205+
}
206+
if old.MaxWorkers != new.MaxWorkers {
207+
log.Printf("Config change: max_workers %d -> %d (requires daemon restart to take effect)", old.MaxWorkers, new.MaxWorkers)
208+
}
209+
if old.ServerAddr != new.ServerAddr {
210+
log.Printf("Config change: server_addr %q -> %q (requires daemon restart to take effect)", old.ServerAddr, new.ServerAddr)
211+
}
212+
}

0 commit comments

Comments
 (0)