ScuttleBot

scuttlebot / internal / api / config_handlers.go
Blame History Raw 316 lines
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 `json:"api_addr"`
15
MCPAddr string `json:"mcp_addr"`
16
Bridge bridgeConfigView `json:"bridge"`
17
Ergo ergoConfigView `json:"ergo"`
18
TLS tlsConfigView `json:"tls"`
19
LLM llmConfigView `json:"llm"`
20
Topology config.TopologyConfig `json:"topology"`
21
History config.ConfigHistoryConfig `json:"config_history"`
22
AgentPolicy config.AgentPolicyConfig `json:"agent_policy"`
23
Logging config.LoggingConfig `json:"logging"`
24
}
25
26
type bridgeConfigView struct {
27
Enabled bool `json:"enabled"`
28
Nick string `json:"nick"`
29
Channels []string `json:"channels"`
30
BufferSize int `json:"buffer_size"`
31
WebUserTTLMinutes int `json:"web_user_ttl_minutes"`
32
// Password intentionally omitted — use PUT with non-empty value to change
33
}
34
35
type ergoConfigView struct {
36
External bool `json:"external"`
37
DataDir string `json:"data_dir"`
38
NetworkName string `json:"network_name"`
39
ServerName string `json:"server_name"`
40
IRCAddr string `json:"irc_addr"`
41
// APIAddr and APIToken omitted (internal/secret)
42
}
43
44
type tlsConfigView struct {
45
Domain string `json:"domain"`
46
Email string `json:"email"`
47
AllowInsecure bool `json:"allow_insecure"`
48
}
49
50
type llmConfigView struct {
51
Backends []llmBackendView `json:"backends"`
52
}
53
54
type llmBackendView struct {
55
Name string `json:"name"`
56
Backend string `json:"backend"`
57
BaseURL string `json:"base_url,omitempty"`
58
Model string `json:"model,omitempty"`
59
Region string `json:"region,omitempty"`
60
Allow []string `json:"allow,omitempty"`
61
Block []string `json:"block,omitempty"`
62
Default bool `json:"default,omitempty"`
63
// APIKey / AWSKeyID / AWSSecretKey omitted — blank = no change on PUT
64
}
65
66
func configToView(cfg config.Config) configView {
67
backends := make([]llmBackendView, len(cfg.LLM.Backends))
68
for i, b := range cfg.LLM.Backends {
69
backends[i] = llmBackendView{
70
Name: b.Name,
71
Backend: b.Backend,
72
BaseURL: b.BaseURL,
73
Model: b.Model,
74
Region: b.Region,
75
Allow: b.Allow,
76
Block: b.Block,
77
Default: b.Default,
78
}
79
}
80
return configView{
81
APIAddr: cfg.APIAddr,
82
MCPAddr: cfg.MCPAddr,
83
Bridge: bridgeConfigView{
84
Enabled: cfg.Bridge.Enabled,
85
Nick: cfg.Bridge.Nick,
86
Channels: cfg.Bridge.Channels,
87
BufferSize: cfg.Bridge.BufferSize,
88
WebUserTTLMinutes: cfg.Bridge.WebUserTTLMinutes,
89
},
90
Ergo: ergoConfigView{
91
External: cfg.Ergo.External,
92
DataDir: cfg.Ergo.DataDir,
93
NetworkName: cfg.Ergo.NetworkName,
94
ServerName: cfg.Ergo.ServerName,
95
IRCAddr: cfg.Ergo.IRCAddr,
96
},
97
TLS: tlsConfigView{
98
Domain: cfg.TLS.Domain,
99
Email: cfg.TLS.Email,
100
AllowInsecure: cfg.TLS.AllowInsecure,
101
},
102
LLM: llmConfigView{Backends: backends},
103
Topology: cfg.Topology,
104
History: cfg.History,
105
AgentPolicy: cfg.AgentPolicy,
106
Logging: cfg.Logging,
107
}
108
}
109
110
// handleGetConfig handles GET /v1/config.
111
func (s *Server) handleGetConfig(w http.ResponseWriter, r *http.Request) {
112
cfg := s.cfgStore.Get()
113
writeJSON(w, http.StatusOK, configToView(cfg))
114
}
115
116
// configUpdateRequest is the body accepted by PUT /v1/config.
117
// Only the mutable, hot-reloadable sections. Restart-required fields (ergo IRC
118
// addr, TLS domain, api_addr) are accepted but flagged in the response.
119
type configUpdateRequest struct {
120
Bridge *bridgeConfigUpdate `json:"bridge,omitempty"`
121
Topology *config.TopologyConfig `json:"topology,omitempty"`
122
History *config.ConfigHistoryConfig `json:"config_history,omitempty"`
123
LLM *llmConfigUpdate `json:"llm,omitempty"`
124
AgentPolicy *config.AgentPolicyConfig `json:"agent_policy,omitempty"`
125
Logging *config.LoggingConfig `json:"logging,omitempty"`
126
Ergo *ergoConfigUpdate `json:"ergo,omitempty"`
127
TLS *tlsConfigUpdate `json:"tls,omitempty"`
128
// These fields trigger a restart_required notice but are still persisted.
129
APIAddr *string `json:"api_addr,omitempty"`
130
MCPAddr *string `json:"mcp_addr,omitempty"`
131
}
132
133
type ergoConfigUpdate struct {
134
NetworkName *string `json:"network_name,omitempty"`
135
ServerName *string `json:"server_name,omitempty"`
136
IRCAddr *string `json:"irc_addr,omitempty"`
137
External *bool `json:"external,omitempty"`
138
}
139
140
type tlsConfigUpdate struct {
141
Domain *string `json:"domain,omitempty"`
142
Email *string `json:"email,omitempty"`
143
AllowInsecure *bool `json:"allow_insecure,omitempty"`
144
}
145
146
type bridgeConfigUpdate struct {
147
Enabled *bool `json:"enabled,omitempty"`
148
Nick *string `json:"nick,omitempty"`
149
Channels []string `json:"channels,omitempty"`
150
BufferSize *int `json:"buffer_size,omitempty"`
151
WebUserTTLMinutes *int `json:"web_user_ttl_minutes,omitempty"`
152
Password *string `json:"password,omitempty"` // blank = no change
153
}
154
155
type llmConfigUpdate struct {
156
Backends []config.LLMBackendConfig `json:"backends"`
157
}
158
159
type configUpdateResponse struct {
160
Saved bool `json:"saved"`
161
RestartRequired []string `json:"restart_required,omitempty"`
162
}
163
164
// handlePutConfig handles PUT /v1/config.
165
func (s *Server) handlePutConfig(w http.ResponseWriter, r *http.Request) {
166
var req configUpdateRequest
167
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
168
writeError(w, http.StatusBadRequest, "invalid request body")
169
return
170
}
171
172
next := s.cfgStore.Get()
173
var restartRequired []string
174
175
if req.Bridge != nil {
176
b := req.Bridge
177
if b.Enabled != nil {
178
next.Bridge.Enabled = *b.Enabled
179
}
180
if b.Nick != nil {
181
next.Bridge.Nick = *b.Nick
182
restartRequired = appendUniq(restartRequired, "bridge.nick")
183
}
184
if b.Channels != nil {
185
next.Bridge.Channels = b.Channels
186
}
187
if b.BufferSize != nil {
188
next.Bridge.BufferSize = *b.BufferSize
189
}
190
if b.WebUserTTLMinutes != nil {
191
next.Bridge.WebUserTTLMinutes = *b.WebUserTTLMinutes
192
}
193
if b.Password != nil && *b.Password != "" {
194
next.Bridge.Password = *b.Password
195
restartRequired = appendUniq(restartRequired, "bridge.password")
196
}
197
}
198
199
if req.Topology != nil {
200
next.Topology = *req.Topology
201
}
202
203
if req.History != nil {
204
if req.History.Keep > 0 {
205
next.History.Keep = req.History.Keep
206
}
207
if req.History.Dir != "" {
208
next.History.Dir = req.History.Dir
209
}
210
}
211
212
if req.LLM != nil {
213
next.LLM.Backends = req.LLM.Backends
214
}
215
216
if req.AgentPolicy != nil {
217
next.AgentPolicy = *req.AgentPolicy
218
}
219
220
if req.Logging != nil {
221
next.Logging = *req.Logging
222
}
223
224
if req.Ergo != nil {
225
e := req.Ergo
226
if e.NetworkName != nil {
227
next.Ergo.NetworkName = *e.NetworkName
228
restartRequired = appendUniq(restartRequired, "ergo.network_name")
229
}
230
if e.ServerName != nil {
231
next.Ergo.ServerName = *e.ServerName
232
restartRequired = appendUniq(restartRequired, "ergo.server_name")
233
}
234
if e.IRCAddr != nil {
235
next.Ergo.IRCAddr = *e.IRCAddr
236
restartRequired = appendUniq(restartRequired, "ergo.irc_addr")
237
}
238
if e.External != nil {
239
next.Ergo.External = *e.External
240
restartRequired = appendUniq(restartRequired, "ergo.external")
241
}
242
}
243
244
if req.TLS != nil {
245
t := req.TLS
246
if t.Domain != nil {
247
next.TLS.Domain = *t.Domain
248
restartRequired = appendUniq(restartRequired, "tls.domain")
249
}
250
if t.Email != nil {
251
next.TLS.Email = *t.Email
252
}
253
if t.AllowInsecure != nil {
254
next.TLS.AllowInsecure = *t.AllowInsecure
255
}
256
}
257
258
if req.APIAddr != nil && *req.APIAddr != "" {
259
next.APIAddr = *req.APIAddr
260
restartRequired = appendUniq(restartRequired, "api_addr")
261
}
262
if req.MCPAddr != nil && *req.MCPAddr != "" {
263
next.MCPAddr = *req.MCPAddr
264
restartRequired = appendUniq(restartRequired, "mcp_addr")
265
}
266
267
if err := s.cfgStore.Save(next); err != nil {
268
s.log.Error("config save failed", "err", err)
269
writeError(w, http.StatusInternalServerError, "failed to save config")
270
return
271
}
272
273
writeJSON(w, http.StatusOK, configUpdateResponse{
274
Saved: true,
275
RestartRequired: restartRequired,
276
})
277
}
278
279
// handleGetConfigHistory handles GET /v1/config/history.
280
func (s *Server) handleGetConfigHistory(w http.ResponseWriter, r *http.Request) {
281
entries, err := s.cfgStore.ListHistory()
282
if err != nil {
283
s.log.Error("list config history", "err", err)
284
writeError(w, http.StatusInternalServerError, "failed to list history")
285
return
286
}
287
writeJSON(w, http.StatusOK, map[string]any{"entries": entries})
288
}
289
290
// handleGetConfigHistoryEntry handles GET /v1/config/history/{filename}.
291
func (s *Server) handleGetConfigHistoryEntry(w http.ResponseWriter, r *http.Request) {
292
filename := filepath.Base(r.PathValue("filename"))
293
data, err := s.cfgStore.ReadHistoryFile(filename)
294
if err != nil {
295
writeError(w, http.StatusNotFound, "snapshot not found")
296
return
297
}
298
// Parse snapshot to return as JSON (same masked view as GET /v1/config).
299
var snapped config.Config
300
snapped.Defaults()
301
if err := snapped.LoadFromBytes(data); err != nil {
302
writeError(w, http.StatusInternalServerError, "failed to parse snapshot")
303
return
304
}
305
writeJSON(w, http.StatusOK, configToView(snapped))
306
}
307
308
func appendUniq(s []string, v string) []string {
309
for _, x := range s {
310
if x == v {
311
return s
312
}
313
}
314
return append(s, v)
315
}
316

Keyboard Shortcuts

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