ScuttleBot

feat: add PATCH /v1/settings/policies for partial policy updates PUT replaces the entire policy object, which wipes unset sections. PATCH merges incoming fields over existing state — behaviors merge by ID, other sections only overwrite when explicitly set.

lmata 2026-04-03 19:01 trunk
Commit c45e13f7214d4cd7b4b80a13bfc65d8103b7bc08576b1224dd9ff12c9b071f79
--- internal/api/policies.go
+++ internal/api/policies.go
@@ -267,10 +267,89 @@
267267
fn := ps.onChange
268268
go fn(snap)
269269
}
270270
return nil
271271
}
272
+
273
+// Merge applies a partial Policies update over the current state. Only
274
+// non-zero fields in the patch overwrite existing values. Behaviors are
275
+// merged by ID — existing behaviors keep their defaults for fields not
276
+// present in the patch.
277
+func (ps *PolicyStore) Merge(patch Policies) error {
278
+ ps.mu.Lock()
279
+ defer ps.mu.Unlock()
280
+
281
+ if len(patch.Behaviors) > 0 {
282
+ incoming := make(map[string]BehaviorConfig, len(patch.Behaviors))
283
+ for _, b := range patch.Behaviors {
284
+ incoming[b.ID] = b
285
+ }
286
+ for i, existing := range ps.data.Behaviors {
287
+ if patched, ok := incoming[existing.ID]; ok {
288
+ // Merge: keep existing defaults, overlay patch fields.
289
+ if patched.Name != "" {
290
+ existing.Name = patched.Name
291
+ }
292
+ if patched.Description != "" {
293
+ existing.Description = patched.Description
294
+ }
295
+ if patched.Nick != "" {
296
+ existing.Nick = patched.Nick
297
+ }
298
+ existing.Enabled = patched.Enabled
299
+ existing.JoinAllChannels = patched.JoinAllChannels
300
+ if patched.ExcludeChannels != nil {
301
+ existing.ExcludeChannels = patched.ExcludeChannels
302
+ }
303
+ if patched.RequiredChannels != nil {
304
+ existing.RequiredChannels = patched.RequiredChannels
305
+ }
306
+ if patched.Config != nil {
307
+ existing.Config = patched.Config
308
+ }
309
+ ps.data.Behaviors[i] = existing
310
+ }
311
+ }
312
+ }
313
+
314
+ // Merge agent_policy if any field is set.
315
+ if patch.AgentPolicy.CheckinChannel != "" || patch.AgentPolicy.RequireCheckin || patch.AgentPolicy.RequiredChannels != nil {
316
+ if patch.AgentPolicy.CheckinChannel != "" {
317
+ ps.data.AgentPolicy.CheckinChannel = patch.AgentPolicy.CheckinChannel
318
+ }
319
+ ps.data.AgentPolicy.RequireCheckin = patch.AgentPolicy.RequireCheckin
320
+ if patch.AgentPolicy.RequiredChannels != nil {
321
+ ps.data.AgentPolicy.RequiredChannels = patch.AgentPolicy.RequiredChannels
322
+ }
323
+ }
324
+
325
+ // Merge bridge if set.
326
+ if patch.Bridge.WebUserTTLMinutes > 0 {
327
+ ps.data.Bridge.WebUserTTLMinutes = patch.Bridge.WebUserTTLMinutes
328
+ }
329
+
330
+ // Merge logging if any field is set.
331
+ if patch.Logging.Dir != "" || patch.Logging.Enabled {
332
+ ps.data.Logging = patch.Logging
333
+ }
334
+
335
+ // Merge LLM backends if provided.
336
+ if patch.LLMBackends != nil {
337
+ ps.data.LLMBackends = patch.LLMBackends
338
+ }
339
+
340
+ ps.normalize(&ps.data)
341
+ if err := ps.save(); err != nil {
342
+ return err
343
+ }
344
+ if ps.onChange != nil {
345
+ snap := ps.data
346
+ fn := ps.onChange
347
+ go fn(snap)
348
+ }
349
+ return nil
350
+}
272351
273352
func (ps *PolicyStore) normalize(p *Policies) {
274353
if p.Bridge.WebUserTTLMinutes <= 0 {
275354
p.Bridge.WebUserTTLMinutes = ps.defaultBridgeTTLMinutes
276355
}
@@ -290,8 +369,22 @@
290369
}
291370
if err := s.policies.Set(p); err != nil {
292371
s.log.Error("save policies", "err", err)
293372
writeError(w, http.StatusInternalServerError, "save failed")
294373
return
374
+ }
375
+ writeJSON(w, http.StatusOK, s.policies.Get())
376
+}
377
+
378
+func (s *Server) handlePatchPolicies(w http.ResponseWriter, r *http.Request) {
379
+ var patch Policies
380
+ if err := json.NewDecoder(r.Body).Decode(&patch); err != nil {
381
+ writeError(w, http.StatusBadRequest, "invalid request body")
382
+ return
383
+ }
384
+ if err := s.policies.Merge(patch); err != nil {
385
+ s.log.Error("merge policies", "err", err)
386
+ writeError(w, http.StatusInternalServerError, "save failed")
387
+ return
295388
}
296389
writeJSON(w, http.StatusOK, s.policies.Get())
297390
}
298391
--- internal/api/policies.go
+++ internal/api/policies.go
@@ -267,10 +267,89 @@
267 fn := ps.onChange
268 go fn(snap)
269 }
270 return nil
271 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
272
273 func (ps *PolicyStore) normalize(p *Policies) {
274 if p.Bridge.WebUserTTLMinutes <= 0 {
275 p.Bridge.WebUserTTLMinutes = ps.defaultBridgeTTLMinutes
276 }
@@ -290,8 +369,22 @@
290 }
291 if err := s.policies.Set(p); err != nil {
292 s.log.Error("save policies", "err", err)
293 writeError(w, http.StatusInternalServerError, "save failed")
294 return
 
 
 
 
 
 
 
 
 
 
 
 
 
 
295 }
296 writeJSON(w, http.StatusOK, s.policies.Get())
297 }
298
--- internal/api/policies.go
+++ internal/api/policies.go
@@ -267,10 +267,89 @@
267 fn := ps.onChange
268 go fn(snap)
269 }
270 return nil
271 }
272
273 // Merge applies a partial Policies update over the current state. Only
274 // non-zero fields in the patch overwrite existing values. Behaviors are
275 // merged by ID — existing behaviors keep their defaults for fields not
276 // present in the patch.
277 func (ps *PolicyStore) Merge(patch Policies) error {
278 ps.mu.Lock()
279 defer ps.mu.Unlock()
280
281 if len(patch.Behaviors) > 0 {
282 incoming := make(map[string]BehaviorConfig, len(patch.Behaviors))
283 for _, b := range patch.Behaviors {
284 incoming[b.ID] = b
285 }
286 for i, existing := range ps.data.Behaviors {
287 if patched, ok := incoming[existing.ID]; ok {
288 // Merge: keep existing defaults, overlay patch fields.
289 if patched.Name != "" {
290 existing.Name = patched.Name
291 }
292 if patched.Description != "" {
293 existing.Description = patched.Description
294 }
295 if patched.Nick != "" {
296 existing.Nick = patched.Nick
297 }
298 existing.Enabled = patched.Enabled
299 existing.JoinAllChannels = patched.JoinAllChannels
300 if patched.ExcludeChannels != nil {
301 existing.ExcludeChannels = patched.ExcludeChannels
302 }
303 if patched.RequiredChannels != nil {
304 existing.RequiredChannels = patched.RequiredChannels
305 }
306 if patched.Config != nil {
307 existing.Config = patched.Config
308 }
309 ps.data.Behaviors[i] = existing
310 }
311 }
312 }
313
314 // Merge agent_policy if any field is set.
315 if patch.AgentPolicy.CheckinChannel != "" || patch.AgentPolicy.RequireCheckin || patch.AgentPolicy.RequiredChannels != nil {
316 if patch.AgentPolicy.CheckinChannel != "" {
317 ps.data.AgentPolicy.CheckinChannel = patch.AgentPolicy.CheckinChannel
318 }
319 ps.data.AgentPolicy.RequireCheckin = patch.AgentPolicy.RequireCheckin
320 if patch.AgentPolicy.RequiredChannels != nil {
321 ps.data.AgentPolicy.RequiredChannels = patch.AgentPolicy.RequiredChannels
322 }
323 }
324
325 // Merge bridge if set.
326 if patch.Bridge.WebUserTTLMinutes > 0 {
327 ps.data.Bridge.WebUserTTLMinutes = patch.Bridge.WebUserTTLMinutes
328 }
329
330 // Merge logging if any field is set.
331 if patch.Logging.Dir != "" || patch.Logging.Enabled {
332 ps.data.Logging = patch.Logging
333 }
334
335 // Merge LLM backends if provided.
336 if patch.LLMBackends != nil {
337 ps.data.LLMBackends = patch.LLMBackends
338 }
339
340 ps.normalize(&ps.data)
341 if err := ps.save(); err != nil {
342 return err
343 }
344 if ps.onChange != nil {
345 snap := ps.data
346 fn := ps.onChange
347 go fn(snap)
348 }
349 return nil
350 }
351
352 func (ps *PolicyStore) normalize(p *Policies) {
353 if p.Bridge.WebUserTTLMinutes <= 0 {
354 p.Bridge.WebUserTTLMinutes = ps.defaultBridgeTTLMinutes
355 }
@@ -290,8 +369,22 @@
369 }
370 if err := s.policies.Set(p); err != nil {
371 s.log.Error("save policies", "err", err)
372 writeError(w, http.StatusInternalServerError, "save failed")
373 return
374 }
375 writeJSON(w, http.StatusOK, s.policies.Get())
376 }
377
378 func (s *Server) handlePatchPolicies(w http.ResponseWriter, r *http.Request) {
379 var patch Policies
380 if err := json.NewDecoder(r.Body).Decode(&patch); err != nil {
381 writeError(w, http.StatusBadRequest, "invalid request body")
382 return
383 }
384 if err := s.policies.Merge(patch); err != nil {
385 s.log.Error("merge policies", "err", err)
386 writeError(w, http.StatusInternalServerError, "save failed")
387 return
388 }
389 writeJSON(w, http.StatusOK, s.policies.Get())
390 }
391
--- internal/api/server.go
+++ internal/api/server.go
@@ -61,10 +61,11 @@
6161
apiMux.HandleFunc("GET /v1/metrics", s.handleMetrics)
6262
if s.policies != nil {
6363
apiMux.HandleFunc("GET /v1/settings", s.handleGetSettings)
6464
apiMux.HandleFunc("GET /v1/settings/policies", s.handleGetPolicies)
6565
apiMux.HandleFunc("PUT /v1/settings/policies", s.handlePutPolicies)
66
+ apiMux.HandleFunc("PATCH /v1/settings/policies", s.handlePatchPolicies)
6667
}
6768
apiMux.HandleFunc("GET /v1/agents", s.handleListAgents)
6869
apiMux.HandleFunc("GET /v1/agents/{nick}", s.handleGetAgent)
6970
apiMux.HandleFunc("PATCH /v1/agents/{nick}", s.handleUpdateAgent)
7071
apiMux.HandleFunc("POST /v1/agents/register", s.handleRegister)
7172
--- internal/api/server.go
+++ internal/api/server.go
@@ -61,10 +61,11 @@
61 apiMux.HandleFunc("GET /v1/metrics", s.handleMetrics)
62 if s.policies != nil {
63 apiMux.HandleFunc("GET /v1/settings", s.handleGetSettings)
64 apiMux.HandleFunc("GET /v1/settings/policies", s.handleGetPolicies)
65 apiMux.HandleFunc("PUT /v1/settings/policies", s.handlePutPolicies)
 
66 }
67 apiMux.HandleFunc("GET /v1/agents", s.handleListAgents)
68 apiMux.HandleFunc("GET /v1/agents/{nick}", s.handleGetAgent)
69 apiMux.HandleFunc("PATCH /v1/agents/{nick}", s.handleUpdateAgent)
70 apiMux.HandleFunc("POST /v1/agents/register", s.handleRegister)
71
--- internal/api/server.go
+++ internal/api/server.go
@@ -61,10 +61,11 @@
61 apiMux.HandleFunc("GET /v1/metrics", s.handleMetrics)
62 if s.policies != nil {
63 apiMux.HandleFunc("GET /v1/settings", s.handleGetSettings)
64 apiMux.HandleFunc("GET /v1/settings/policies", s.handleGetPolicies)
65 apiMux.HandleFunc("PUT /v1/settings/policies", s.handlePutPolicies)
66 apiMux.HandleFunc("PATCH /v1/settings/policies", s.handlePatchPolicies)
67 }
68 apiMux.HandleFunc("GET /v1/agents", s.handleListAgents)
69 apiMux.HandleFunc("GET /v1/agents/{nick}", s.handleGetAgent)
70 apiMux.HandleFunc("PATCH /v1/agents/{nick}", s.handleUpdateAgent)
71 apiMux.HandleFunc("POST /v1/agents/register", s.handleRegister)
72

Keyboard Shortcuts

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