ScuttleBot

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

Keyboard Shortcuts

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