ScuttleBot

feat(#43): unified config store — write-through to scuttlebot.yaml with history - Config.Save(path): marshal+atomic-write via tmp rename - Duration.MarshalYAML: round-trips "72h" format so topology TTLs survive save/load - Config.LoadFromBytes: extracted from LoadFile for reuse in history endpoint - ConfigHistoryConfig{Keep, Dir}: new config section (default keep=20) - internal/config/history.go: SnapshotConfig, PruneHistory, ListHistory — timestamped snapshots in data/config-history/, oldest pruned to keep limit - internal/api/ConfigStore: thread-safe store holding running config + write path — Save() snapshots, writes file, prunes history, fires onChange callbacks - GET /v1/config: masked JSON view of running config (secrets omitted) - PUT /v1/config: partial update → snapshot → save → hot-reload callbacks — topology and bridge.web_user_ttl hot-reload; restart-required sections flagged - GET /v1/config/history: list snapshots - GET /v1/config/history/{filename}: fetch snapshot as masked JSON - Wire ConfigStore + topology/bridge hot-reload in cmd/scuttlebot/main.go - Tests: round-trip, snapshot/prune, GET/PUT /v1/config, history list

lmata 2026-04-02 17:24 trunk
Commit 17e2c1d1e649742ac0d54db85ef4d3db84500899e2335f72c3457de1261919c8
--- cmd/scuttlebot/main.go
+++ cmd/scuttlebot/main.go
@@ -259,17 +259,39 @@
259259
Config: b.Config,
260260
}
261261
}
262262
botMgr.Sync(ctx, specs)
263263
}
264
+
265
+ // Config store — owns write-back to scuttlebot.yaml with history snapshots.
266
+ cfgStore := api.NewConfigStore(*configPath, *cfg)
267
+ cfgStore.OnChange(func(updated config.Config) {
268
+ // Hot-reload topology on config change.
269
+ if topoMgr != nil {
270
+ staticChannels := make([]topology.ChannelConfig, 0, len(updated.Topology.Channels))
271
+ for _, sc := range updated.Topology.Channels {
272
+ staticChannels = append(staticChannels, topology.ChannelConfig{
273
+ Name: sc.Name, Topic: sc.Topic,
274
+ Ops: sc.Ops, Voice: sc.Voice, Autojoin: sc.Autojoin,
275
+ })
276
+ }
277
+ if err := topoMgr.Provision(staticChannels); err != nil {
278
+ log.Error("topology hot-reload failed", "err", err)
279
+ }
280
+ }
281
+ // Hot-reload bridge web TTL.
282
+ if bridgeBot != nil {
283
+ bridgeBot.SetWebUserTTL(time.Duration(updated.Bridge.WebUserTTLMinutes) * time.Minute)
284
+ }
285
+ })
264286
265287
// Start HTTP REST API server.
266288
var llmCfg *config.LLMConfig
267289
if len(cfg.LLM.Backends) > 0 {
268290
llmCfg = &cfg.LLM
269291
}
270
- apiSrv := api.New(reg, tokens, bridgeBot, policyStore, adminStore, llmCfg, topoMgr, cfg.TLS.Domain, log)
292
+ apiSrv := api.New(reg, tokens, bridgeBot, policyStore, adminStore, llmCfg, topoMgr, cfgStore, cfg.TLS.Domain, log)
271293
handler := apiSrv.Handler()
272294
273295
var httpServer, tlsServer *http.Server
274296
275297
if cfg.TLS.Domain != "" {
276298
--- cmd/scuttlebot/main.go
+++ cmd/scuttlebot/main.go
@@ -259,17 +259,39 @@
259 Config: b.Config,
260 }
261 }
262 botMgr.Sync(ctx, specs)
263 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
264
265 // Start HTTP REST API server.
266 var llmCfg *config.LLMConfig
267 if len(cfg.LLM.Backends) > 0 {
268 llmCfg = &cfg.LLM
269 }
270 apiSrv := api.New(reg, tokens, bridgeBot, policyStore, adminStore, llmCfg, topoMgr, cfg.TLS.Domain, log)
271 handler := apiSrv.Handler()
272
273 var httpServer, tlsServer *http.Server
274
275 if cfg.TLS.Domain != "" {
276
--- cmd/scuttlebot/main.go
+++ cmd/scuttlebot/main.go
@@ -259,17 +259,39 @@
259 Config: b.Config,
260 }
261 }
262 botMgr.Sync(ctx, specs)
263 }
264
265 // Config store — owns write-back to scuttlebot.yaml with history snapshots.
266 cfgStore := api.NewConfigStore(*configPath, *cfg)
267 cfgStore.OnChange(func(updated config.Config) {
268 // Hot-reload topology on config change.
269 if topoMgr != nil {
270 staticChannels := make([]topology.ChannelConfig, 0, len(updated.Topology.Channels))
271 for _, sc := range updated.Topology.Channels {
272 staticChannels = append(staticChannels, topology.ChannelConfig{
273 Name: sc.Name, Topic: sc.Topic,
274 Ops: sc.Ops, Voice: sc.Voice, Autojoin: sc.Autojoin,
275 })
276 }
277 if err := topoMgr.Provision(staticChannels); err != nil {
278 log.Error("topology hot-reload failed", "err", err)
279 }
280 }
281 // Hot-reload bridge web TTL.
282 if bridgeBot != nil {
283 bridgeBot.SetWebUserTTL(time.Duration(updated.Bridge.WebUserTTLMinutes) * time.Minute)
284 }
285 })
286
287 // Start HTTP REST API server.
288 var llmCfg *config.LLMConfig
289 if len(cfg.LLM.Backends) > 0 {
290 llmCfg = &cfg.LLM
291 }
292 apiSrv := api.New(reg, tokens, bridgeBot, policyStore, adminStore, llmCfg, topoMgr, cfgStore, cfg.TLS.Domain, log)
293 handler := apiSrv.Handler()
294
295 var httpServer, tlsServer *http.Server
296
297 if cfg.TLS.Domain != "" {
298
--- internal/api/api_test.go
+++ internal/api/api_test.go
@@ -50,11 +50,11 @@
5050
const testToken = "test-api-token-abc123"
5151
5252
func newTestServer(t *testing.T) *httptest.Server {
5353
t.Helper()
5454
reg := registry.New(newMock(), []byte("test-signing-key"))
55
- srv := api.New(reg, []string{testToken}, nil, nil, nil, nil, nil, "", testLog)
55
+ srv := api.New(reg, []string{testToken}, nil, nil, nil, nil, nil, nil, "", testLog)
5656
return httptest.NewServer(srv.Handler())
5757
}
5858
5959
func authHeader() http.Header {
6060
h := http.Header{}
6161
--- internal/api/api_test.go
+++ internal/api/api_test.go
@@ -50,11 +50,11 @@
50 const testToken = "test-api-token-abc123"
51
52 func newTestServer(t *testing.T) *httptest.Server {
53 t.Helper()
54 reg := registry.New(newMock(), []byte("test-signing-key"))
55 srv := api.New(reg, []string{testToken}, nil, nil, nil, nil, nil, "", testLog)
56 return httptest.NewServer(srv.Handler())
57 }
58
59 func authHeader() http.Header {
60 h := http.Header{}
61
--- internal/api/api_test.go
+++ internal/api/api_test.go
@@ -50,11 +50,11 @@
50 const testToken = "test-api-token-abc123"
51
52 func newTestServer(t *testing.T) *httptest.Server {
53 t.Helper()
54 reg := registry.New(newMock(), []byte("test-signing-key"))
55 srv := api.New(reg, []string{testToken}, nil, nil, nil, nil, nil, nil, "", testLog)
56 return httptest.NewServer(srv.Handler())
57 }
58
59 func authHeader() http.Header {
60 h := http.Header{}
61
--- internal/api/channels_topology_test.go
+++ internal/api/channels_topology_test.go
@@ -34,11 +34,11 @@
3434
3535
func newTopoTestServer(t *testing.T, topo *stubTopologyManager) (*httptest.Server, string) {
3636
t.Helper()
3737
reg := registry.New(nil, []byte("key"))
3838
log := slog.New(slog.NewTextHandler(io.Discard, nil))
39
- srv := httptest.NewServer(New(reg, []string{"tok"}, nil, nil, nil, nil, topo, "", log).Handler())
39
+ srv := httptest.NewServer(New(reg, []string{"tok"}, nil, nil, nil, nil, topo, nil, "", log).Handler())
4040
t.Cleanup(srv.Close)
4141
return srv, "tok"
4242
}
4343
4444
func TestHandleProvisionChannel(t *testing.T) {
4545
--- internal/api/channels_topology_test.go
+++ internal/api/channels_topology_test.go
@@ -34,11 +34,11 @@
34
35 func newTopoTestServer(t *testing.T, topo *stubTopologyManager) (*httptest.Server, string) {
36 t.Helper()
37 reg := registry.New(nil, []byte("key"))
38 log := slog.New(slog.NewTextHandler(io.Discard, nil))
39 srv := httptest.NewServer(New(reg, []string{"tok"}, nil, nil, nil, nil, topo, "", log).Handler())
40 t.Cleanup(srv.Close)
41 return srv, "tok"
42 }
43
44 func TestHandleProvisionChannel(t *testing.T) {
45
--- internal/api/channels_topology_test.go
+++ internal/api/channels_topology_test.go
@@ -34,11 +34,11 @@
34
35 func newTopoTestServer(t *testing.T, topo *stubTopologyManager) (*httptest.Server, string) {
36 t.Helper()
37 reg := registry.New(nil, []byte("key"))
38 log := slog.New(slog.NewTextHandler(io.Discard, nil))
39 srv := httptest.NewServer(New(reg, []string{"tok"}, nil, nil, nil, nil, topo, nil, "", log).Handler())
40 t.Cleanup(srv.Close)
41 return srv, "tok"
42 }
43
44 func TestHandleProvisionChannel(t *testing.T) {
45
--- internal/api/chat_test.go
+++ internal/api/chat_test.go
@@ -39,11 +39,11 @@
3939
t.Helper()
4040
4141
bridgeStub := &stubChatBridge{}
4242
reg := registry.New(nil, []byte("test-signing-key"))
4343
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
44
- srv := httptest.NewServer(New(reg, []string{"token"}, bridgeStub, nil, nil, nil, nil, "", logger).Handler())
44
+ srv := httptest.NewServer(New(reg, []string{"token"}, bridgeStub, nil, nil, nil, nil, nil, "", logger).Handler())
4545
defer srv.Close()
4646
4747
body, _ := json.Marshal(map[string]string{"nick": "codex-test"})
4848
req, err := http.NewRequest(http.MethodPost, srv.URL+"/v1/channels/general/presence", bytes.NewReader(body))
4949
if err != nil {
@@ -72,11 +72,11 @@
7272
t.Helper()
7373
7474
bridgeStub := &stubChatBridge{}
7575
reg := registry.New(nil, []byte("test-signing-key"))
7676
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
77
- srv := httptest.NewServer(New(reg, []string{"token"}, bridgeStub, nil, nil, nil, nil, "", logger).Handler())
77
+ srv := httptest.NewServer(New(reg, []string{"token"}, bridgeStub, nil, nil, nil, nil, nil, "", logger).Handler())
7878
defer srv.Close()
7979
8080
body, _ := json.Marshal(map[string]string{})
8181
req, err := http.NewRequest(http.MethodPost, srv.URL+"/v1/channels/general/presence", bytes.NewReader(body))
8282
if err != nil {
8383
8484
ADDED internal/api/config_handlers.go
8585
ADDED internal/api/config_handlers_test.go
8686
ADDED internal/api/config_store.go
--- internal/api/chat_test.go
+++ internal/api/chat_test.go
@@ -39,11 +39,11 @@
39 t.Helper()
40
41 bridgeStub := &stubChatBridge{}
42 reg := registry.New(nil, []byte("test-signing-key"))
43 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
44 srv := httptest.NewServer(New(reg, []string{"token"}, bridgeStub, nil, nil, nil, nil, "", logger).Handler())
45 defer srv.Close()
46
47 body, _ := json.Marshal(map[string]string{"nick": "codex-test"})
48 req, err := http.NewRequest(http.MethodPost, srv.URL+"/v1/channels/general/presence", bytes.NewReader(body))
49 if err != nil {
@@ -72,11 +72,11 @@
72 t.Helper()
73
74 bridgeStub := &stubChatBridge{}
75 reg := registry.New(nil, []byte("test-signing-key"))
76 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
77 srv := httptest.NewServer(New(reg, []string{"token"}, bridgeStub, nil, nil, nil, nil, "", logger).Handler())
78 defer srv.Close()
79
80 body, _ := json.Marshal(map[string]string{})
81 req, err := http.NewRequest(http.MethodPost, srv.URL+"/v1/channels/general/presence", bytes.NewReader(body))
82 if err != nil {
83
84 DDED internal/api/config_handlers.go
85 DDED internal/api/config_handlers_test.go
86 DDED internal/api/config_store.go
--- internal/api/chat_test.go
+++ internal/api/chat_test.go
@@ -39,11 +39,11 @@
39 t.Helper()
40
41 bridgeStub := &stubChatBridge{}
42 reg := registry.New(nil, []byte("test-signing-key"))
43 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
44 srv := httptest.NewServer(New(reg, []string{"token"}, bridgeStub, nil, nil, nil, nil, nil, "", logger).Handler())
45 defer srv.Close()
46
47 body, _ := json.Marshal(map[string]string{"nick": "codex-test"})
48 req, err := http.NewRequest(http.MethodPost, srv.URL+"/v1/channels/general/presence", bytes.NewReader(body))
49 if err != nil {
@@ -72,11 +72,11 @@
72 t.Helper()
73
74 bridgeStub := &stubChatBridge{}
75 reg := registry.New(nil, []byte("test-signing-key"))
76 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
77 srv := httptest.NewServer(New(reg, []string{"token"}, bridgeStub, nil, nil, nil, nil, nil, "", logger).Handler())
78 defer srv.Close()
79
80 body, _ := json.Marshal(map[string]string{})
81 req, err := http.NewRequest(http.MethodPost, srv.URL+"/v1/channels/general/presence", bytes.NewReader(body))
82 if err != nil {
83
84 DDED internal/api/config_handlers.go
85 DDED internal/api/config_handlers_test.go
86 DDED internal/api/config_store.go
--- a/internal/api/config_handlers.go
+++ b/internal/api/config_handlers.go
@@ -0,0 +1,28 @@
1
+package api
2
+
3
+import (
4
+ "encoding/json"
5
+ "net/http"
6
+ "path/filepath"
7
+
8
+ "github.com/conflicthq/scuttlebot/internal/config"
9
+)
10
+
11
+// configView is the JSON shape returned by GET /v1/config.
12
+// Secrets are masked — zero values mean "no change" on PUT.
13
+type configView struct {
14
+ APIAddr string string string `json:"mcp_addr"`
15
+ Bridge bridgeConfigView bridgeConfigView ergoConfigView `json:"ergo"`
16
+ TLS `json:"ergo"`
17
+ TLS `json:"tls"`
18
+ LLM llmConfigView `json:"llm"`
19
+ Topology UT
20
+}
21
+
22
+func configToView(`json:"topology"`
23
+ History make([]llmBackendView"`
24
+}
25
+
26
+type bridgeConfigView struct APIAddr and APITokecfg.Topology,
27
+ cfg.History,
28
+ }M@23G,6p@pW,S@wN,b@wm,S@xQ,d@xp,S@yV,1: M@Ay,N@zG,S@zf,C: `json:"llm2w@13U,6:bridgeN@16T,I@Dl,Sr@1Dl,Rh@1vO,H20W9;
--- a/internal/api/config_handlers.go
+++ b/internal/api/config_handlers.go
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/internal/api/config_handlers.go
+++ b/internal/api/config_handlers.go
@@ -0,0 +1,28 @@
1 package api
2
3 import (
4 "encoding/json"
5 "net/http"
6 "path/filepath"
7
8 "github.com/conflicthq/scuttlebot/internal/config"
9 )
10
11 // configView is the JSON shape returned by GET /v1/config.
12 // Secrets are masked — zero values mean "no change" on PUT.
13 type configView struct {
14 APIAddr string string string `json:"mcp_addr"`
15 Bridge bridgeConfigView bridgeConfigView ergoConfigView `json:"ergo"`
16 TLS `json:"ergo"`
17 TLS `json:"tls"`
18 LLM llmConfigView `json:"llm"`
19 Topology UT
20 }
21
22 func configToView(`json:"topology"`
23 History make([]llmBackendView"`
24 }
25
26 type bridgeConfigView struct APIAddr and APITokecfg.Topology,
27 cfg.History,
28 }M@23G,6p@pW,S@wN,b@wm,S@xQ,d@xp,S@yV,1: M@Ay,N@zG,S@zf,C: `json:"llm2w@13U,6:bridgeN@16T,I@Dl,Sr@1Dl,Rh@1vO,H20W9;
--- a/internal/api/config_handlers_test.go
+++ b/internal/api/config_handlers_test.go
@@ -0,0 +1,27 @@
1
+:= json.Marshal(upody, _ := json.Marshal(update)
2
+ req, _ := http.NewRequest(http.MethodPut, srv.URL+"/v1/config", bytes.NewReader(body))
3
+ req.Header.Set("Authorization", "Bearer tok")
4
+ req.Header.Set("Content-Type", "application/json")
5
+ resp, err := http.DefaultClient.Do(req)
6
+ if err != nil {
7
+ t.Fatal(err)
8
+ }
9
+ defer resp.Body.Close()
10
+ if resp.StatusCode != http.StatusOK {
11
+ t.Fatalf("want 200, got %d", resp.StatusCode)
12
+ }
13
+
14
+ got := store.Get()
15
+ if !got.Logging.Enabled {
16
+ t.Error("logging.enabled should be true")
17
+ }
18
+ if got.Logging.Dir != "./data/logs" {
19
+ t.Errorf("logging.dir = %q, want ./data/logs", got.Logging.Dir)
20
+ }
21
+ if got.Logging.Format != "jsonl" {
22
+ t.Errorf("logging.format = %q, want jsonl", got.Logging.Format)
23
+ }
24
+ if got.Logging.Rotation != "daily" {
25
+ t.Errorf("logging.rotation = %q, want daily", got.Logging.Rotation)
26
+ }
27
+ if !got.Logging.PerCh
--- a/internal/api/config_handlers_test.go
+++ b/internal/api/config_handlers_test.go
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/internal/api/config_handlers_test.go
+++ b/internal/api/config_handlers_test.go
@@ -0,0 +1,27 @@
1 := json.Marshal(upody, _ := json.Marshal(update)
2 req, _ := http.NewRequest(http.MethodPut, srv.URL+"/v1/config", bytes.NewReader(body))
3 req.Header.Set("Authorization", "Bearer tok")
4 req.Header.Set("Content-Type", "application/json")
5 resp, err := http.DefaultClient.Do(req)
6 if err != nil {
7 t.Fatal(err)
8 }
9 defer resp.Body.Close()
10 if resp.StatusCode != http.StatusOK {
11 t.Fatalf("want 200, got %d", resp.StatusCode)
12 }
13
14 got := store.Get()
15 if !got.Logging.Enabled {
16 t.Error("logging.enabled should be true")
17 }
18 if got.Logging.Dir != "./data/logs" {
19 t.Errorf("logging.dir = %q, want ./data/logs", got.Logging.Dir)
20 }
21 if got.Logging.Format != "jsonl" {
22 t.Errorf("logging.format = %q, want jsonl", got.Logging.Format)
23 }
24 if got.Logging.Rotation != "daily" {
25 t.Errorf("logging.rotation = %q, want daily", got.Logging.Rotation)
26 }
27 if !got.Logging.PerCh
--- a/internal/api/config_store.go
+++ b/internal/api/config_store.go
@@ -0,0 +1,111 @@
1
+package api
2
+
3
+import (
4
+ "fmt"
5
+ "os"
6
+ "path/filepath"
7
+ "sync"
8
+
9
+ "github.com/conflicthq/scuttlebot/internal/config"
10
+)
11
+
12
+// ConfigStore holds the running config and knows how to write it back to disk
13
+// with history snapshots. It is the single write path for all config mutations.
14
+type ConfigStore struct {
15
+ mu sync.RWMutex
16
+ cfg config.Config
17
+ path string // absolute path to scuttlebot.yaml
18
+ historyDir string // where snapshots land
19
+ onChange []func(config.Config)
20
+}
21
+
22
+// NewConfigStore creates a ConfigStore for the given config file path.
23
+// The initial config value is copied in.
24
+func NewConfigStore(path string, cfg config.Config) *ConfigStore {
25
+ histDir := cfg.History.Dir
26
+ if histDir == "" {
27
+ histDir = filepath.Join(cfg.Ergo.DataDir, "config-history")
28
+ }
29
+ return &ConfigStore{
30
+ cfg: cfg,
31
+ path: path,
32
+ historyDir: histDir,
33
+ }
34
+}
35
+
36
+// Get returns a copy of the current config.
37
+func (s *ConfigStore) Get() config.Config {
38
+ s.mu.RLock()
39
+ defer s.mu.RUnlock()
40
+ return s.cfg
41
+}
42
+
43
+// OnChange registers a callback invoked (in a new goroutine) after every
44
+// successful Save. Multiple callbacks are called concurrently.
45
+func (s *ConfigStore) OnChange(fn func(config.Config)) {
46
+ s.mu.Lock()
47
+ defer s.mu.Unlock()
48
+ s.onChange = append(s.onChange, fn)
49
+}
50
+
51
+// Save snapshots the current file, writes next to disk, updates the in-memory
52
+// copy, and fires all OnChange callbacks.
53
+func (s *ConfigStore) Save(next config.Config) error {
54
+ s.mu.Lock()
55
+ defer s.mu.Unlock()
56
+
57
+ keep := next.History.Keep
58
+ if keep == 0 {
59
+ keep = 20 // safety fallback; keep=0 in config means "use default"
60
+ }
61
+
62
+ // Snapshot before overwrite (no-op if file doesn't exist yet).
63
+ if err := config.SnapshotConfig(s.historyDir, s.path); err != nil {
64
+ return fmt.Errorf("config store: snapshot: %w", err)
65
+ }
66
+
67
+ // Write the new config to disk.
68
+ if err := next.Save(s.path); err != nil {
69
+ return fmt.Errorf("config store: save: %w", err)
70
+ }
71
+
72
+ // Prune history to keep entries.
73
+ base := filepath.Base(s.path)
74
+ if err := config.PruneHistory(s.historyDir, base, keep); err != nil {
75
+ // Non-fatal: log would be nice but we don't have a logger here.
76
+ // Callers can surface this separately.
77
+ _ = err
78
+ }
79
+
80
+ s.cfg = next
81
+
82
+ // Fire callbacks outside the lock in fresh goroutines.
83
+ cbs := make([]func(config.Config), len(s.onChange))
84
+ copy(cbs, s.onChange)
85
+ for _, fn := range cbs {
86
+ go fn(next)
87
+ }
88
+ return nil
89
+}
90
+
91
+// ListHistory returns the snapshots for the managed config file.
92
+func (s *ConfigStore) ListHistory() ([]config.HistoryEntry, error) {
93
+ s.mu.RLock()
94
+ histDir := s.historyDir
95
+ path := s.path
96
+ s.mu.RUnlock()
97
+ base := filepath.Base(path)
98
+ return config.ListHistory(histDir, base)
99
+}
100
+
101
+// ReadHistoryFile returns the raw bytes of a snapshot by filename.
102
+func (s *ConfigStore) ReadHistoryFile(filename string) ([]byte, error) {
103
+ s.mu.RLock()
104
+ histDir := s.historyDir
105
+ s.mu.RUnlock()
106
+ // Sanitize: only allow simple filenames (no path separators).
107
+ if filepath.Base(filename) != filename {
108
+ return nil, fmt.Errorf("config store: invalid history filename")
109
+ }
110
+ return os.ReadFile(filepath.Join(histDir, filename))
111
+}
--- a/internal/api/config_store.go
+++ b/internal/api/config_store.go
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/internal/api/config_store.go
+++ b/internal/api/config_store.go
@@ -0,0 +1,111 @@
1 package api
2
3 import (
4 "fmt"
5 "os"
6 "path/filepath"
7 "sync"
8
9 "github.com/conflicthq/scuttlebot/internal/config"
10 )
11
12 // ConfigStore holds the running config and knows how to write it back to disk
13 // with history snapshots. It is the single write path for all config mutations.
14 type ConfigStore struct {
15 mu sync.RWMutex
16 cfg config.Config
17 path string // absolute path to scuttlebot.yaml
18 historyDir string // where snapshots land
19 onChange []func(config.Config)
20 }
21
22 // NewConfigStore creates a ConfigStore for the given config file path.
23 // The initial config value is copied in.
24 func NewConfigStore(path string, cfg config.Config) *ConfigStore {
25 histDir := cfg.History.Dir
26 if histDir == "" {
27 histDir = filepath.Join(cfg.Ergo.DataDir, "config-history")
28 }
29 return &ConfigStore{
30 cfg: cfg,
31 path: path,
32 historyDir: histDir,
33 }
34 }
35
36 // Get returns a copy of the current config.
37 func (s *ConfigStore) Get() config.Config {
38 s.mu.RLock()
39 defer s.mu.RUnlock()
40 return s.cfg
41 }
42
43 // OnChange registers a callback invoked (in a new goroutine) after every
44 // successful Save. Multiple callbacks are called concurrently.
45 func (s *ConfigStore) OnChange(fn func(config.Config)) {
46 s.mu.Lock()
47 defer s.mu.Unlock()
48 s.onChange = append(s.onChange, fn)
49 }
50
51 // Save snapshots the current file, writes next to disk, updates the in-memory
52 // copy, and fires all OnChange callbacks.
53 func (s *ConfigStore) Save(next config.Config) error {
54 s.mu.Lock()
55 defer s.mu.Unlock()
56
57 keep := next.History.Keep
58 if keep == 0 {
59 keep = 20 // safety fallback; keep=0 in config means "use default"
60 }
61
62 // Snapshot before overwrite (no-op if file doesn't exist yet).
63 if err := config.SnapshotConfig(s.historyDir, s.path); err != nil {
64 return fmt.Errorf("config store: snapshot: %w", err)
65 }
66
67 // Write the new config to disk.
68 if err := next.Save(s.path); err != nil {
69 return fmt.Errorf("config store: save: %w", err)
70 }
71
72 // Prune history to keep entries.
73 base := filepath.Base(s.path)
74 if err := config.PruneHistory(s.historyDir, base, keep); err != nil {
75 // Non-fatal: log would be nice but we don't have a logger here.
76 // Callers can surface this separately.
77 _ = err
78 }
79
80 s.cfg = next
81
82 // Fire callbacks outside the lock in fresh goroutines.
83 cbs := make([]func(config.Config), len(s.onChange))
84 copy(cbs, s.onChange)
85 for _, fn := range cbs {
86 go fn(next)
87 }
88 return nil
89 }
90
91 // ListHistory returns the snapshots for the managed config file.
92 func (s *ConfigStore) ListHistory() ([]config.HistoryEntry, error) {
93 s.mu.RLock()
94 histDir := s.historyDir
95 path := s.path
96 s.mu.RUnlock()
97 base := filepath.Base(path)
98 return config.ListHistory(histDir, base)
99 }
100
101 // ReadHistoryFile returns the raw bytes of a snapshot by filename.
102 func (s *ConfigStore) ReadHistoryFile(filename string) ([]byte, error) {
103 s.mu.RLock()
104 histDir := s.historyDir
105 s.mu.RUnlock()
106 // Sanitize: only allow simple filenames (no path separators).
107 if filepath.Base(filename) != filename {
108 return nil, fmt.Errorf("config store: invalid history filename")
109 }
110 return os.ReadFile(filepath.Join(histDir, filename))
111 }
--- internal/api/login_test.go
+++ internal/api/login_test.go
@@ -28,18 +28,18 @@
2828
admins := newAdminStore(t)
2929
if err := admins.Add("admin", "hunter2"); err != nil {
3030
t.Fatalf("Add admin: %v", err)
3131
}
3232
reg := registry.New(newMock(), []byte("test-signing-key"))
33
- srv := api.New(reg, []string{testToken}, nil, nil, admins, nil, nil, "", testLog)
33
+ srv := api.New(reg, []string{testToken}, nil, nil, admins, nil, nil, nil, "", testLog)
3434
return httptest.NewServer(srv.Handler()), admins
3535
}
3636
3737
func TestLoginNoAdmins(t *testing.T) {
3838
// When admins is nil, login returns 404.
3939
reg := registry.New(newMock(), []byte("test-signing-key"))
40
- srv := api.New(reg, []string{testToken}, nil, nil, nil, nil, nil, "", testLog)
40
+ srv := api.New(reg, []string{testToken}, nil, nil, nil, nil, nil, nil, "", testLog)
4141
ts := httptest.NewServer(srv.Handler())
4242
defer ts.Close()
4343
4444
resp := do(t, ts, "POST", "/login", map[string]any{"username": "admin", "password": "pw"}, nil)
4545
defer resp.Body.Close()
4646
--- internal/api/login_test.go
+++ internal/api/login_test.go
@@ -28,18 +28,18 @@
28 admins := newAdminStore(t)
29 if err := admins.Add("admin", "hunter2"); err != nil {
30 t.Fatalf("Add admin: %v", err)
31 }
32 reg := registry.New(newMock(), []byte("test-signing-key"))
33 srv := api.New(reg, []string{testToken}, nil, nil, admins, nil, nil, "", testLog)
34 return httptest.NewServer(srv.Handler()), admins
35 }
36
37 func TestLoginNoAdmins(t *testing.T) {
38 // When admins is nil, login returns 404.
39 reg := registry.New(newMock(), []byte("test-signing-key"))
40 srv := api.New(reg, []string{testToken}, nil, nil, nil, nil, nil, "", testLog)
41 ts := httptest.NewServer(srv.Handler())
42 defer ts.Close()
43
44 resp := do(t, ts, "POST", "/login", map[string]any{"username": "admin", "password": "pw"}, nil)
45 defer resp.Body.Close()
46
--- internal/api/login_test.go
+++ internal/api/login_test.go
@@ -28,18 +28,18 @@
28 admins := newAdminStore(t)
29 if err := admins.Add("admin", "hunter2"); err != nil {
30 t.Fatalf("Add admin: %v", err)
31 }
32 reg := registry.New(newMock(), []byte("test-signing-key"))
33 srv := api.New(reg, []string{testToken}, nil, nil, admins, nil, nil, nil, "", testLog)
34 return httptest.NewServer(srv.Handler()), admins
35 }
36
37 func TestLoginNoAdmins(t *testing.T) {
38 // When admins is nil, login returns 404.
39 reg := registry.New(newMock(), []byte("test-signing-key"))
40 srv := api.New(reg, []string{testToken}, nil, nil, nil, nil, nil, nil, "", testLog)
41 ts := httptest.NewServer(srv.Handler())
42 defer ts.Close()
43
44 resp := do(t, ts, "POST", "/login", map[string]any{"username": "admin", "password": "pw"}, nil)
45 defer resp.Body.Close()
46
--- internal/api/server.go
+++ internal/api/server.go
@@ -21,19 +21,21 @@
2121
bridge chatBridge // nil if bridge is disabled
2222
policies *PolicyStore // nil if not configured
2323
admins adminStore // nil if not configured
2424
llmCfg *config.LLMConfig // nil if no LLM backends configured
2525
topoMgr topologyManager // nil if topology not configured
26
+ cfgStore *ConfigStore // nil if config write-back not configured
2627
loginRL *loginRateLimiter
2728
tlsDomain string // empty if no TLS
2829
}
2930
3031
// New creates a new API Server. Pass nil for b to disable the chat bridge.
3132
// Pass nil for admins to disable admin authentication endpoints.
3233
// Pass nil for llmCfg to disable AI/LLM management endpoints.
3334
// Pass nil for topo to disable topology provisioning endpoints.
34
-func New(reg *registry.Registry, tokens []string, b chatBridge, ps *PolicyStore, admins adminStore, llmCfg *config.LLMConfig, topo topologyManager, tlsDomain string, log *slog.Logger) *Server {
35
+// Pass nil for cfgStore to disable config read/write endpoints.
36
+func New(reg *registry.Registry, tokens []string, b chatBridge, ps *PolicyStore, admins adminStore, llmCfg *config.LLMConfig, topo topologyManager, cfgStore *ConfigStore, tlsDomain string, log *slog.Logger) *Server {
3537
tokenSet := make(map[string]struct{}, len(tokens))
3638
for _, t := range tokens {
3739
tokenSet[t] = struct{}{}
3840
}
3941
return &Server{
@@ -43,10 +45,11 @@
4345
bridge: b,
4446
policies: ps,
4547
admins: admins,
4648
llmCfg: llmCfg,
4749
topoMgr: topo,
50
+ cfgStore: cfgStore,
4851
loginRL: newLoginRateLimiter(),
4952
tlsDomain: tlsDomain,
5053
}
5154
}
5255
@@ -81,10 +84,16 @@
8184
if s.topoMgr != nil {
8285
apiMux.HandleFunc("POST /v1/channels", s.handleProvisionChannel)
8386
apiMux.HandleFunc("DELETE /v1/topology/channels/{channel}", s.handleDropChannel)
8487
apiMux.HandleFunc("GET /v1/topology", s.handleGetTopology)
8588
}
89
+ if s.cfgStore != nil {
90
+ apiMux.HandleFunc("GET /v1/config", s.handleGetConfig)
91
+ apiMux.HandleFunc("PUT /v1/config", s.handlePutConfig)
92
+ apiMux.HandleFunc("GET /v1/config/history", s.handleGetConfigHistory)
93
+ apiMux.HandleFunc("GET /v1/config/history/{filename}", s.handleGetConfigHistoryEntry)
94
+ }
8695
8796
if s.admins != nil {
8897
apiMux.HandleFunc("GET /v1/admins", s.handleAdminList)
8998
apiMux.HandleFunc("POST /v1/admins", s.handleAdminAdd)
9099
apiMux.HandleFunc("DELETE /v1/admins/{username}", s.handleAdminRemove)
91100
--- internal/api/server.go
+++ internal/api/server.go
@@ -21,19 +21,21 @@
21 bridge chatBridge // nil if bridge is disabled
22 policies *PolicyStore // nil if not configured
23 admins adminStore // nil if not configured
24 llmCfg *config.LLMConfig // nil if no LLM backends configured
25 topoMgr topologyManager // nil if topology not configured
 
26 loginRL *loginRateLimiter
27 tlsDomain string // empty if no TLS
28 }
29
30 // New creates a new API Server. Pass nil for b to disable the chat bridge.
31 // Pass nil for admins to disable admin authentication endpoints.
32 // Pass nil for llmCfg to disable AI/LLM management endpoints.
33 // Pass nil for topo to disable topology provisioning endpoints.
34 func New(reg *registry.Registry, tokens []string, b chatBridge, ps *PolicyStore, admins adminStore, llmCfg *config.LLMConfig, topo topologyManager, tlsDomain string, log *slog.Logger) *Server {
 
35 tokenSet := make(map[string]struct{}, len(tokens))
36 for _, t := range tokens {
37 tokenSet[t] = struct{}{}
38 }
39 return &Server{
@@ -43,10 +45,11 @@
43 bridge: b,
44 policies: ps,
45 admins: admins,
46 llmCfg: llmCfg,
47 topoMgr: topo,
 
48 loginRL: newLoginRateLimiter(),
49 tlsDomain: tlsDomain,
50 }
51 }
52
@@ -81,10 +84,16 @@
81 if s.topoMgr != nil {
82 apiMux.HandleFunc("POST /v1/channels", s.handleProvisionChannel)
83 apiMux.HandleFunc("DELETE /v1/topology/channels/{channel}", s.handleDropChannel)
84 apiMux.HandleFunc("GET /v1/topology", s.handleGetTopology)
85 }
 
 
 
 
 
 
86
87 if s.admins != nil {
88 apiMux.HandleFunc("GET /v1/admins", s.handleAdminList)
89 apiMux.HandleFunc("POST /v1/admins", s.handleAdminAdd)
90 apiMux.HandleFunc("DELETE /v1/admins/{username}", s.handleAdminRemove)
91
--- internal/api/server.go
+++ internal/api/server.go
@@ -21,19 +21,21 @@
21 bridge chatBridge // nil if bridge is disabled
22 policies *PolicyStore // nil if not configured
23 admins adminStore // nil if not configured
24 llmCfg *config.LLMConfig // nil if no LLM backends configured
25 topoMgr topologyManager // nil if topology not configured
26 cfgStore *ConfigStore // nil if config write-back not configured
27 loginRL *loginRateLimiter
28 tlsDomain string // empty if no TLS
29 }
30
31 // New creates a new API Server. Pass nil for b to disable the chat bridge.
32 // Pass nil for admins to disable admin authentication endpoints.
33 // Pass nil for llmCfg to disable AI/LLM management endpoints.
34 // Pass nil for topo to disable topology provisioning endpoints.
35 // Pass nil for cfgStore to disable config read/write endpoints.
36 func New(reg *registry.Registry, tokens []string, b chatBridge, ps *PolicyStore, admins adminStore, llmCfg *config.LLMConfig, topo topologyManager, cfgStore *ConfigStore, tlsDomain string, log *slog.Logger) *Server {
37 tokenSet := make(map[string]struct{}, len(tokens))
38 for _, t := range tokens {
39 tokenSet[t] = struct{}{}
40 }
41 return &Server{
@@ -43,10 +45,11 @@
45 bridge: b,
46 policies: ps,
47 admins: admins,
48 llmCfg: llmCfg,
49 topoMgr: topo,
50 cfgStore: cfgStore,
51 loginRL: newLoginRateLimiter(),
52 tlsDomain: tlsDomain,
53 }
54 }
55
@@ -81,10 +84,16 @@
84 if s.topoMgr != nil {
85 apiMux.HandleFunc("POST /v1/channels", s.handleProvisionChannel)
86 apiMux.HandleFunc("DELETE /v1/topology/channels/{channel}", s.handleDropChannel)
87 apiMux.HandleFunc("GET /v1/topology", s.handleGetTopology)
88 }
89 if s.cfgStore != nil {
90 apiMux.HandleFunc("GET /v1/config", s.handleGetConfig)
91 apiMux.HandleFunc("PUT /v1/config", s.handlePutConfig)
92 apiMux.HandleFunc("GET /v1/config/history", s.handleGetConfigHistory)
93 apiMux.HandleFunc("GET /v1/config/history/{filename}", s.handleGetConfigHistoryEntry)
94 }
95
96 if s.admins != nil {
97 apiMux.HandleFunc("GET /v1/admins", s.handleAdminList)
98 apiMux.HandleFunc("POST /v1/admins", s.handleAdminAdd)
99 apiMux.HandleFunc("DELETE /v1/admins/{username}", s.handleAdminRemove)
100
--- internal/config/config.go
+++ internal/config/config.go
@@ -9,16 +9,17 @@
99
"gopkg.in/yaml.v3"
1010
)
1111
1212
// Config is the top-level scuttlebot configuration.
1313
type Config struct {
14
- Ergo ErgoConfig `yaml:"ergo"`
15
- Datastore DatastoreConfig `yaml:"datastore"`
16
- Bridge BridgeConfig `yaml:"bridge"`
17
- TLS TLSConfig `yaml:"tls"`
18
- LLM LLMConfig `yaml:"llm"`
19
- Topology TopologyConfig `yaml:"topology"`
14
+ Ergo ErgoConfig `yaml:"ergo"`
15
+ Datastore DatastoreConfig `yaml:"datastore"`
16
+ Bridge BridgeConfig `yaml:"bridge"`
17
+ TLS TLSConfig `yaml:"tls"`
18
+ LLM LLMConfig `yaml:"llm"`
19
+ Topology TopologyConfig `yaml:"topology"`
20
+ History ConfigHistoryConfig `yaml:"config_history"`
2021
2122
// APIAddr is the address for scuttlebot's own HTTP management API.
2223
// Ignored when TLS.Domain is set (HTTPS runs on :443, HTTP on :80).
2324
// Default: ":8080"
2425
APIAddr string `yaml:"api_addr"`
@@ -25,10 +26,21 @@
2526
2627
// MCPAddr is the address for the MCP server.
2728
// Default: ":8081"
2829
MCPAddr string `yaml:"mcp_addr"`
2930
}
31
+
32
+// ConfigHistoryConfig controls config write-back history retention.
33
+type ConfigHistoryConfig struct {
34
+ // Keep is the number of config snapshots to retain in Dir.
35
+ // 0 disables history. Default: 20.
36
+ Keep int `yaml:"keep"`
37
+
38
+ // Dir is the directory for config snapshots.
39
+ // Default: {ergo.data_dir}/config-history
40
+ Dir string `yaml:"dir"`
41
+}
3042
3143
// LLMConfig configures the omnibus LLM gateway used by oracle and any other
3244
// bot or service that needs language model access.
3345
type LLMConfig struct {
3446
// Backends is the list of configured LLM backends.
@@ -270,10 +282,38 @@
270282
return fmt.Errorf("config: invalid duration %q: %w", s, err)
271283
}
272284
d.Duration = dur
273285
return nil
274286
}
287
+
288
+// MarshalYAML encodes Duration as a human-readable string ("72h", "30m").
289
+func (d Duration) MarshalYAML() (any, error) {
290
+ if d.Duration == 0 {
291
+ return "0s", nil
292
+ }
293
+ return d.Duration.String(), nil
294
+}
295
+
296
+// Save marshals c to YAML and writes it to path atomically (write to a temp
297
+// file in the same directory, then rename). Comments in the original file are
298
+// not preserved after the first save.
299
+func (c *Config) Save(path string) error {
300
+ data, err := yaml.Marshal(c)
301
+ if err != nil {
302
+ return fmt.Errorf("config: marshal: %w", err)
303
+ }
304
+ // Write to a sibling temp file then rename for atomic replacement.
305
+ tmp := path + ".tmp"
306
+ if err := os.WriteFile(tmp, data, 0o600); err != nil {
307
+ return fmt.Errorf("config: write %s: %w", tmp, err)
308
+ }
309
+ if err := os.Rename(tmp, path); err != nil {
310
+ _ = os.Remove(tmp)
311
+ return fmt.Errorf("config: rename %s → %s: %w", tmp, path, err)
312
+ }
313
+ return nil
314
+}
275315
276316
// Defaults fills in zero values with sensible defaults.
277317
func (c *Config) Defaults() {
278318
if c.Ergo.BinaryPath == "" {
279319
c.Ergo.BinaryPath = "ergo"
@@ -321,10 +361,13 @@
321361
c.Bridge.WebUserTTLMinutes = 5
322362
}
323363
if c.Topology.Nick == "" {
324364
c.Topology.Nick = "topology"
325365
}
366
+ if c.History.Keep == 0 {
367
+ c.History.Keep = 20
368
+ }
326369
}
327370
328371
func envStr(key string) string { return os.Getenv(key) }
329372
330373
// LoadFile reads a YAML config file into c. Missing file is not an error —
@@ -337,12 +380,17 @@
337380
return nil
338381
}
339382
if err != nil {
340383
return fmt.Errorf("config: read %s: %w", path, err)
341384
}
385
+ return c.LoadFromBytes(data)
386
+}
387
+
388
+// LoadFromBytes parses YAML config bytes into c.
389
+func (c *Config) LoadFromBytes(data []byte) error {
342390
if err := yaml.Unmarshal(data, c); err != nil {
343
- return fmt.Errorf("config: parse %s: %w", path, err)
391
+ return fmt.Errorf("config: parse: %w", err)
344392
}
345393
return nil
346394
}
347395
348396
// ApplyEnv overrides config values with SCUTTLEBOT_* environment variables.
349397
350398
ADDED internal/config/history.go
351399
ADDED internal/config/history_test.go
--- internal/config/config.go
+++ internal/config/config.go
@@ -9,16 +9,17 @@
9 "gopkg.in/yaml.v3"
10 )
11
12 // Config is the top-level scuttlebot configuration.
13 type Config struct {
14 Ergo ErgoConfig `yaml:"ergo"`
15 Datastore DatastoreConfig `yaml:"datastore"`
16 Bridge BridgeConfig `yaml:"bridge"`
17 TLS TLSConfig `yaml:"tls"`
18 LLM LLMConfig `yaml:"llm"`
19 Topology TopologyConfig `yaml:"topology"`
 
20
21 // APIAddr is the address for scuttlebot's own HTTP management API.
22 // Ignored when TLS.Domain is set (HTTPS runs on :443, HTTP on :80).
23 // Default: ":8080"
24 APIAddr string `yaml:"api_addr"`
@@ -25,10 +26,21 @@
25
26 // MCPAddr is the address for the MCP server.
27 // Default: ":8081"
28 MCPAddr string `yaml:"mcp_addr"`
29 }
 
 
 
 
 
 
 
 
 
 
 
30
31 // LLMConfig configures the omnibus LLM gateway used by oracle and any other
32 // bot or service that needs language model access.
33 type LLMConfig struct {
34 // Backends is the list of configured LLM backends.
@@ -270,10 +282,38 @@
270 return fmt.Errorf("config: invalid duration %q: %w", s, err)
271 }
272 d.Duration = dur
273 return nil
274 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
275
276 // Defaults fills in zero values with sensible defaults.
277 func (c *Config) Defaults() {
278 if c.Ergo.BinaryPath == "" {
279 c.Ergo.BinaryPath = "ergo"
@@ -321,10 +361,13 @@
321 c.Bridge.WebUserTTLMinutes = 5
322 }
323 if c.Topology.Nick == "" {
324 c.Topology.Nick = "topology"
325 }
 
 
 
326 }
327
328 func envStr(key string) string { return os.Getenv(key) }
329
330 // LoadFile reads a YAML config file into c. Missing file is not an error —
@@ -337,12 +380,17 @@
337 return nil
338 }
339 if err != nil {
340 return fmt.Errorf("config: read %s: %w", path, err)
341 }
 
 
 
 
 
342 if err := yaml.Unmarshal(data, c); err != nil {
343 return fmt.Errorf("config: parse %s: %w", path, err)
344 }
345 return nil
346 }
347
348 // ApplyEnv overrides config values with SCUTTLEBOT_* environment variables.
349
350 DDED internal/config/history.go
351 DDED internal/config/history_test.go
--- internal/config/config.go
+++ internal/config/config.go
@@ -9,16 +9,17 @@
9 "gopkg.in/yaml.v3"
10 )
11
12 // Config is the top-level scuttlebot configuration.
13 type Config struct {
14 Ergo ErgoConfig `yaml:"ergo"`
15 Datastore DatastoreConfig `yaml:"datastore"`
16 Bridge BridgeConfig `yaml:"bridge"`
17 TLS TLSConfig `yaml:"tls"`
18 LLM LLMConfig `yaml:"llm"`
19 Topology TopologyConfig `yaml:"topology"`
20 History ConfigHistoryConfig `yaml:"config_history"`
21
22 // APIAddr is the address for scuttlebot's own HTTP management API.
23 // Ignored when TLS.Domain is set (HTTPS runs on :443, HTTP on :80).
24 // Default: ":8080"
25 APIAddr string `yaml:"api_addr"`
@@ -25,10 +26,21 @@
26
27 // MCPAddr is the address for the MCP server.
28 // Default: ":8081"
29 MCPAddr string `yaml:"mcp_addr"`
30 }
31
32 // ConfigHistoryConfig controls config write-back history retention.
33 type ConfigHistoryConfig struct {
34 // Keep is the number of config snapshots to retain in Dir.
35 // 0 disables history. Default: 20.
36 Keep int `yaml:"keep"`
37
38 // Dir is the directory for config snapshots.
39 // Default: {ergo.data_dir}/config-history
40 Dir string `yaml:"dir"`
41 }
42
43 // LLMConfig configures the omnibus LLM gateway used by oracle and any other
44 // bot or service that needs language model access.
45 type LLMConfig struct {
46 // Backends is the list of configured LLM backends.
@@ -270,10 +282,38 @@
282 return fmt.Errorf("config: invalid duration %q: %w", s, err)
283 }
284 d.Duration = dur
285 return nil
286 }
287
288 // MarshalYAML encodes Duration as a human-readable string ("72h", "30m").
289 func (d Duration) MarshalYAML() (any, error) {
290 if d.Duration == 0 {
291 return "0s", nil
292 }
293 return d.Duration.String(), nil
294 }
295
296 // Save marshals c to YAML and writes it to path atomically (write to a temp
297 // file in the same directory, then rename). Comments in the original file are
298 // not preserved after the first save.
299 func (c *Config) Save(path string) error {
300 data, err := yaml.Marshal(c)
301 if err != nil {
302 return fmt.Errorf("config: marshal: %w", err)
303 }
304 // Write to a sibling temp file then rename for atomic replacement.
305 tmp := path + ".tmp"
306 if err := os.WriteFile(tmp, data, 0o600); err != nil {
307 return fmt.Errorf("config: write %s: %w", tmp, err)
308 }
309 if err := os.Rename(tmp, path); err != nil {
310 _ = os.Remove(tmp)
311 return fmt.Errorf("config: rename %s → %s: %w", tmp, path, err)
312 }
313 return nil
314 }
315
316 // Defaults fills in zero values with sensible defaults.
317 func (c *Config) Defaults() {
318 if c.Ergo.BinaryPath == "" {
319 c.Ergo.BinaryPath = "ergo"
@@ -321,10 +361,13 @@
361 c.Bridge.WebUserTTLMinutes = 5
362 }
363 if c.Topology.Nick == "" {
364 c.Topology.Nick = "topology"
365 }
366 if c.History.Keep == 0 {
367 c.History.Keep = 20
368 }
369 }
370
371 func envStr(key string) string { return os.Getenv(key) }
372
373 // LoadFile reads a YAML config file into c. Missing file is not an error —
@@ -337,12 +380,17 @@
380 return nil
381 }
382 if err != nil {
383 return fmt.Errorf("config: read %s: %w", path, err)
384 }
385 return c.LoadFromBytes(data)
386 }
387
388 // LoadFromBytes parses YAML config bytes into c.
389 func (c *Config) LoadFromBytes(data []byte) error {
390 if err := yaml.Unmarshal(data, c); err != nil {
391 return fmt.Errorf("config: parse: %w", err)
392 }
393 return nil
394 }
395
396 // ApplyEnv overrides config values with SCUTTLEBOT_* environment variables.
397
398 DDED internal/config/history.go
399 DDED internal/config/history_test.go
--- a/internal/config/history.go
+++ b/internal/config/history.go
@@ -0,0 +1,120 @@
1
+package config
2
+
3
+import (
4
+ "fmt"
5
+ "io"
6
+ "os"
7
+ "path/filepath"
8
+ "sort"
9
+ "strings"
10
+ "time"
11
+)
12
+
13
+// HistoryEntry describes a single config snapshot in the history directory.
14
+type HistoryEntry struct {
15
+ // Filename is the base name of the snapshot file (e.g. "scuttlebot.yaml.20260402-143022").
16
+ Filename string `json:"filename"`
17
+
18
+ // Timestamp is when the snapshot was taken, parsed from the filename.
19
+ Timestamp time.Time `json:"timestamp"`
20
+
21
+ // Size is the file size in bytes.
22
+ Size int64 `json:"size"`
23
+}
24
+
25
+const historyTimestampFormat = "20060102-150405"
26
+
27
+// SnapshotConfig copies the file at configPath into historyDir, naming it
28
+// "<basename>.<timestamp>". It creates historyDir if it does not exist.
29
+// It is a no-op if configPath does not exist yet.
30
+func SnapshotConfig(historyDir, configPath string) error {
31
+ src, err := os.Open(configPath)
32
+ if os.IsNotExist(err) {
33
+ return nil // nothing to snapshot
34
+ }
35
+ if err != nil {
36
+ return fmt.Errorf("config history: open %s: %w", configPath, err)
37
+ }
38
+ defer src.Close()
39
+
40
+ if err := os.MkdirAll(historyDir, 0o700); err != nil {
41
+ return fmt.Errorf("config history: mkdir %s: %w", historyDir, err)
42
+ }
43
+
44
+ base := filepath.Base(configPath)
45
+ stamp := time.Now().Format(historyTimestampFormat)
46
+ dst := filepath.Join(historyDir, base+"."+stamp)
47
+
48
+ out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
49
+ if err != nil {
50
+ return fmt.Errorf("config history: create snapshot %s: %w", dst, err)
51
+ }
52
+ defer out.Close()
53
+
54
+ if _, err := io.Copy(out, src); err != nil {
55
+ return fmt.Errorf("config history: write snapshot %s: %w", dst, err)
56
+ }
57
+ return nil
58
+}
59
+
60
+// PruneHistory removes the oldest snapshots in historyDir until at most keep
61
+// files remain. It only considers files whose names start with base (the
62
+// basename of the config file). keep ≤ 0 means unlimited (no pruning).
63
+func PruneHistory(historyDir, base string, keep int) error {
64
+ if keep <= 0 {
65
+ return nil
66
+ }
67
+ entries, err := listHistory(historyDir, base)
68
+ if err != nil {
69
+ return err
70
+ }
71
+ for len(entries) > keep {
72
+ oldest := entries[0]
73
+ if err := os.Remove(filepath.Join(historyDir, oldest.Filename)); err != nil && !os.IsNotExist(err) {
74
+ return fmt.Errorf("config history: remove %s: %w", oldest.Filename, err)
75
+ }
76
+ entries = entries[1:]
77
+ }
78
+ return nil
79
+}
80
+
81
+// ListHistory returns all snapshots for base (the config file basename) in
82
+// historyDir, sorted oldest-first.
83
+func ListHistory(historyDir, base string) ([]HistoryEntry, error) {
84
+ return listHistory(historyDir, base)
85
+}
86
+
87
+func listHistory(historyDir, base string) ([]HistoryEntry, error) {
88
+ des, err := os.ReadDir(historyDir)
89
+ if os.IsNotExist(err) {
90
+ return nil, nil
91
+ }
92
+ if err != nil {
93
+ return nil, fmt.Errorf("config history: readdir %s: %w", historyDir, err)
94
+ }
95
+ var out []HistoryEntry
96
+ prefix := base + "."
97
+ for _, de := range des {
98
+ if de.IsDir() || !strings.HasPrefix(de.Name(), prefix) {
99
+ continue
100
+ }
101
+ stamp := strings.TrimPrefix(de.Name(), prefix)
102
+ t, err := time.ParseInLocation(historyTimestampFormat, stamp, time.Local)
103
+ if err != nil {
104
+ continue // skip files with non-matching suffix
105
+ }
106
+ info, err := de.Info()
107
+ if err != nil {
108
+ continue
109
+ }
110
+ out = append(out, HistoryEntry{
111
+ Filename: de.Name(),
112
+ Timestamp: t,
113
+ Size: info.Size(),
114
+ })
115
+ }
116
+ sort.Slice(out, func(i, j int) bool {
117
+ return out[i].Timestamp.Before(out[j].Timestamp)
118
+ })
119
+ return out, nil
120
+}
--- a/internal/config/history.go
+++ b/internal/config/history.go
@@ -0,0 +1,120 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/internal/config/history.go
+++ b/internal/config/history.go
@@ -0,0 +1,120 @@
1 package config
2
3 import (
4 "fmt"
5 "io"
6 "os"
7 "path/filepath"
8 "sort"
9 "strings"
10 "time"
11 )
12
13 // HistoryEntry describes a single config snapshot in the history directory.
14 type HistoryEntry struct {
15 // Filename is the base name of the snapshot file (e.g. "scuttlebot.yaml.20260402-143022").
16 Filename string `json:"filename"`
17
18 // Timestamp is when the snapshot was taken, parsed from the filename.
19 Timestamp time.Time `json:"timestamp"`
20
21 // Size is the file size in bytes.
22 Size int64 `json:"size"`
23 }
24
25 const historyTimestampFormat = "20060102-150405"
26
27 // SnapshotConfig copies the file at configPath into historyDir, naming it
28 // "<basename>.<timestamp>". It creates historyDir if it does not exist.
29 // It is a no-op if configPath does not exist yet.
30 func SnapshotConfig(historyDir, configPath string) error {
31 src, err := os.Open(configPath)
32 if os.IsNotExist(err) {
33 return nil // nothing to snapshot
34 }
35 if err != nil {
36 return fmt.Errorf("config history: open %s: %w", configPath, err)
37 }
38 defer src.Close()
39
40 if err := os.MkdirAll(historyDir, 0o700); err != nil {
41 return fmt.Errorf("config history: mkdir %s: %w", historyDir, err)
42 }
43
44 base := filepath.Base(configPath)
45 stamp := time.Now().Format(historyTimestampFormat)
46 dst := filepath.Join(historyDir, base+"."+stamp)
47
48 out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
49 if err != nil {
50 return fmt.Errorf("config history: create snapshot %s: %w", dst, err)
51 }
52 defer out.Close()
53
54 if _, err := io.Copy(out, src); err != nil {
55 return fmt.Errorf("config history: write snapshot %s: %w", dst, err)
56 }
57 return nil
58 }
59
60 // PruneHistory removes the oldest snapshots in historyDir until at most keep
61 // files remain. It only considers files whose names start with base (the
62 // basename of the config file). keep ≤ 0 means unlimited (no pruning).
63 func PruneHistory(historyDir, base string, keep int) error {
64 if keep <= 0 {
65 return nil
66 }
67 entries, err := listHistory(historyDir, base)
68 if err != nil {
69 return err
70 }
71 for len(entries) > keep {
72 oldest := entries[0]
73 if err := os.Remove(filepath.Join(historyDir, oldest.Filename)); err != nil && !os.IsNotExist(err) {
74 return fmt.Errorf("config history: remove %s: %w", oldest.Filename, err)
75 }
76 entries = entries[1:]
77 }
78 return nil
79 }
80
81 // ListHistory returns all snapshots for base (the config file basename) in
82 // historyDir, sorted oldest-first.
83 func ListHistory(historyDir, base string) ([]HistoryEntry, error) {
84 return listHistory(historyDir, base)
85 }
86
87 func listHistory(historyDir, base string) ([]HistoryEntry, error) {
88 des, err := os.ReadDir(historyDir)
89 if os.IsNotExist(err) {
90 return nil, nil
91 }
92 if err != nil {
93 return nil, fmt.Errorf("config history: readdir %s: %w", historyDir, err)
94 }
95 var out []HistoryEntry
96 prefix := base + "."
97 for _, de := range des {
98 if de.IsDir() || !strings.HasPrefix(de.Name(), prefix) {
99 continue
100 }
101 stamp := strings.TrimPrefix(de.Name(), prefix)
102 t, err := time.ParseInLocation(historyTimestampFormat, stamp, time.Local)
103 if err != nil {
104 continue // skip files with non-matching suffix
105 }
106 info, err := de.Info()
107 if err != nil {
108 continue
109 }
110 out = append(out, HistoryEntry{
111 Filename: de.Name(),
112 Timestamp: t,
113 Size: info.Size(),
114 })
115 }
116 sort.Slice(out, func(i, j int) bool {
117 return out[i].Timestamp.Before(out[j].Timestamp)
118 })
119 return out, nil
120 }
--- a/internal/config/history_test.go
+++ b/internal/config/history_test.go
@@ -0,0 +1,104 @@
1
+package config
2
+
3
+import (
4
+ "os"
5
+ "path/filepath"
6
+ "testing"
7
+ "time"
8
+)
9
+
10
+func TestSnapshotAndPrune(t *testing.T) {
11
+ dir := t.TempDir()
12
+ histDir := filepath.Join(dir, "history")
13
+ configPath := filepath.Join(dir, "scuttlebot.yaml")
14
+
15
+ // No-op when config file doesn't exist yet.
16
+ if err := SnapshotConfig(histDir, configPath); err != nil {
17
+ t.Fatalf("SnapshotConfig (no file): %v", err)
18
+ }
19
+
20
+ // Write a config file and snapshot it.
21
+ if err := os.WriteFile(configPath, []byte("bridge:\n enabled: true\n"), 0o600); err != nil {
22
+ t.Fatal(err)
23
+ }
24
+ if err := SnapshotConfig(histDir, configPath); err != nil {
25
+ t.Fatalf("SnapshotConfig: %v", err)
26
+ }
27
+
28
+ entries, err := ListHistory(histDir, "scuttlebot.yaml")
29
+ if err != nil {
30
+ t.Fatal(err)
31
+ }
32
+ if len(entries) != 1 {
33
+ t.Fatalf("want 1 snapshot, got %d", len(entries))
34
+ }
35
+ if entries[0].Size == 0 {
36
+ t.Error("snapshot size should be non-zero")
37
+ }
38
+ if entries[0].Timestamp.IsZero() {
39
+ t.Error("snapshot timestamp should be set")
40
+ }
41
+}
42
+
43
+func TestPruneHistory(t *testing.T) {
44
+ dir := t.TempDir()
45
+ base := "scuttlebot.yaml"
46
+ keep := 3
47
+
48
+ // Write 5 snapshot files with distinct timestamps.
49
+ for i := range 5 {
50
+ stamp := time.Now().Add(time.Duration(i) * time.Second).Format(historyTimestampFormat)
51
+ name := filepath.Join(dir, base+"."+stamp)
52
+ if err := os.WriteFile(name, []byte("v"), 0o600); err != nil {
53
+ t.Fatal(err)
54
+ }
55
+ // Ensure distinct mtime ordering.
56
+ time.Sleep(2 * time.Millisecond)
57
+ }
58
+
59
+ if err := PruneHistory(dir, base, keep); err != nil {
60
+ t.Fatal(err)
61
+ }
62
+
63
+ entries, err := ListHistory(dir, base)
64
+ if err != nil {
65
+ t.Fatal(err)
66
+ }
67
+ if len(entries) != keep {
68
+ t.Errorf("want %d entries after prune, got %d", keep, len(entries))
69
+ }
70
+}
71
+
72
+func TestConfigSaveRoundTrip(t *testing.T) {
73
+ dir := t.TempDir()
74
+ path := filepath.Join(dir, "scuttlebot.yaml")
75
+
76
+ var cfg Config
77
+ cfg.Defaults()
78
+ cfg.Topology.Channels = []StaticChannelConfig{
79
+ {Name: "#general", Topic: "Fleet coordination", Autojoin: []string{"bridge"}},
80
+ }
81
+ cfg.Topology.Types = []ChannelTypeConfig{
82
+ {Name: "task", Prefix: "task.", Ephemeral: true, TTL: Duration{72 * time.Hour}},
83
+ }
84
+
85
+ if err := cfg.Save(path); err != nil {
86
+ t.Fatalf("Save: %v", err)
87
+ }
88
+
89
+ var loaded Config
90
+ loaded.Defaults()
91
+ if err := loaded.LoadFile(path); err != nil {
92
+ t.Fatalf("LoadFile: %v", err)
93
+ }
94
+
95
+ if len(loaded.Topology.Channels) != 1 || loaded.Topology.Channels[0].Name != "#general" {
96
+ t.Errorf("topology channels not round-tripped: %+v", loaded.Topology.Channels)
97
+ }
98
+ if len(loaded.Topology.Types) != 1 || loaded.Topology.Types[0].Name != "task" {
99
+ t.Errorf("topology types not round-tripped: %+v", loaded.Topology.Types)
100
+ }
101
+ if loaded.Topology.Types[0].TTL.Duration != 72*time.Hour {
102
+ t.Errorf("TTL = %v, want 72h", loaded.Topology.Types[0].TTL.Duration)
103
+ }
104
+}
--- a/internal/config/history_test.go
+++ b/internal/config/history_test.go
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/internal/config/history_test.go
+++ b/internal/config/history_test.go
@@ -0,0 +1,104 @@
1 package config
2
3 import (
4 "os"
5 "path/filepath"
6 "testing"
7 "time"
8 )
9
10 func TestSnapshotAndPrune(t *testing.T) {
11 dir := t.TempDir()
12 histDir := filepath.Join(dir, "history")
13 configPath := filepath.Join(dir, "scuttlebot.yaml")
14
15 // No-op when config file doesn't exist yet.
16 if err := SnapshotConfig(histDir, configPath); err != nil {
17 t.Fatalf("SnapshotConfig (no file): %v", err)
18 }
19
20 // Write a config file and snapshot it.
21 if err := os.WriteFile(configPath, []byte("bridge:\n enabled: true\n"), 0o600); err != nil {
22 t.Fatal(err)
23 }
24 if err := SnapshotConfig(histDir, configPath); err != nil {
25 t.Fatalf("SnapshotConfig: %v", err)
26 }
27
28 entries, err := ListHistory(histDir, "scuttlebot.yaml")
29 if err != nil {
30 t.Fatal(err)
31 }
32 if len(entries) != 1 {
33 t.Fatalf("want 1 snapshot, got %d", len(entries))
34 }
35 if entries[0].Size == 0 {
36 t.Error("snapshot size should be non-zero")
37 }
38 if entries[0].Timestamp.IsZero() {
39 t.Error("snapshot timestamp should be set")
40 }
41 }
42
43 func TestPruneHistory(t *testing.T) {
44 dir := t.TempDir()
45 base := "scuttlebot.yaml"
46 keep := 3
47
48 // Write 5 snapshot files with distinct timestamps.
49 for i := range 5 {
50 stamp := time.Now().Add(time.Duration(i) * time.Second).Format(historyTimestampFormat)
51 name := filepath.Join(dir, base+"."+stamp)
52 if err := os.WriteFile(name, []byte("v"), 0o600); err != nil {
53 t.Fatal(err)
54 }
55 // Ensure distinct mtime ordering.
56 time.Sleep(2 * time.Millisecond)
57 }
58
59 if err := PruneHistory(dir, base, keep); err != nil {
60 t.Fatal(err)
61 }
62
63 entries, err := ListHistory(dir, base)
64 if err != nil {
65 t.Fatal(err)
66 }
67 if len(entries) != keep {
68 t.Errorf("want %d entries after prune, got %d", keep, len(entries))
69 }
70 }
71
72 func TestConfigSaveRoundTrip(t *testing.T) {
73 dir := t.TempDir()
74 path := filepath.Join(dir, "scuttlebot.yaml")
75
76 var cfg Config
77 cfg.Defaults()
78 cfg.Topology.Channels = []StaticChannelConfig{
79 {Name: "#general", Topic: "Fleet coordination", Autojoin: []string{"bridge"}},
80 }
81 cfg.Topology.Types = []ChannelTypeConfig{
82 {Name: "task", Prefix: "task.", Ephemeral: true, TTL: Duration{72 * time.Hour}},
83 }
84
85 if err := cfg.Save(path); err != nil {
86 t.Fatalf("Save: %v", err)
87 }
88
89 var loaded Config
90 loaded.Defaults()
91 if err := loaded.LoadFile(path); err != nil {
92 t.Fatalf("LoadFile: %v", err)
93 }
94
95 if len(loaded.Topology.Channels) != 1 || loaded.Topology.Channels[0].Name != "#general" {
96 t.Errorf("topology channels not round-tripped: %+v", loaded.Topology.Channels)
97 }
98 if len(loaded.Topology.Types) != 1 || loaded.Topology.Types[0].Name != "task" {
99 t.Errorf("topology types not round-tripped: %+v", loaded.Topology.Types)
100 }
101 if loaded.Topology.Types[0].TTL.Duration != 72*time.Hour {
102 t.Errorf("TTL = %v, want 72h", loaded.Topology.Types[0].TTL.Duration)
103 }
104 }

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button