Add GET/POST /settings handlers with injected applier
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"antidrift/internal/settings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// SetSettings injects the persisted settings, their file path, and the applier
|
||||
// closure (built by main, which owns adapter construction). Mirrors the
|
||||
// controller's Set* injection so web never imports ai/tasks/knowledge.
|
||||
func (s *Server) SetSettings(path string, current settings.Settings, apply func(settings.Settings) error) {
|
||||
s.settingsMu.Lock()
|
||||
s.settingsPath = path
|
||||
s.settings = current
|
||||
s.applyFn = apply
|
||||
s.settingsMu.Unlock()
|
||||
}
|
||||
|
||||
func (s *Server) handleGetSettings(c *gin.Context) {
|
||||
s.settingsMu.Lock()
|
||||
cur := s.settings
|
||||
s.settingsMu.Unlock()
|
||||
c.JSON(http.StatusOK, cur)
|
||||
}
|
||||
|
||||
// handlePostSettings validates-and-applies atomically, then persists. The applier
|
||||
// checks the backend before mutating any state, so an invalid backend yields 400
|
||||
// with nothing saved and the prior wiring intact.
|
||||
func (s *Server) handlePostSettings(c *gin.Context) {
|
||||
var req settings.Settings
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json"})
|
||||
return
|
||||
}
|
||||
s.settingsMu.Lock()
|
||||
apply := s.applyFn
|
||||
path := s.settingsPath
|
||||
s.settingsMu.Unlock()
|
||||
if apply == nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "settings not wired"})
|
||||
return
|
||||
}
|
||||
if err := apply(req); err != nil {
|
||||
// Apply validates the backend before mutating anything, so any error
|
||||
// (invalid backend or otherwise) means nothing changed; surface it as 400.
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := settings.Save(path, req); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
s.settingsMu.Lock()
|
||||
s.settings = req
|
||||
s.settingsMu.Unlock()
|
||||
s.broadcast()
|
||||
c.JSON(http.StatusOK, req)
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"antidrift/internal/settings"
|
||||
)
|
||||
|
||||
// fakeApplier records the last settings it was asked to apply and can be told to
|
||||
// reject with ErrInvalidBackend.
|
||||
type fakeApplier struct {
|
||||
called int
|
||||
last settings.Settings
|
||||
reject bool
|
||||
}
|
||||
|
||||
func (f *fakeApplier) apply(s settings.Settings) error {
|
||||
if f.reject {
|
||||
return settings.ErrInvalidBackend
|
||||
}
|
||||
f.called++
|
||||
f.last = s
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestGetSettingsReturnsCurrent(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
cur := settings.Settings{AIBackend: "claude", MarvinCmd: "am", KnowledgePath: "/tmp/k.md"}
|
||||
fa := &fakeApplier{}
|
||||
s.SetSettings(filepath.Join(t.TempDir(), "settings.json"), cur, fa.apply)
|
||||
r := s.Router()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/settings", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", w.Code)
|
||||
}
|
||||
var got settings.Settings
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if got != cur {
|
||||
t.Errorf("got %+v, want %+v", got, cur)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostSettingsValidAppliesAndSaves(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
path := filepath.Join(t.TempDir(), "settings.json")
|
||||
fa := &fakeApplier{}
|
||||
s.SetSettings(path, settings.Settings{}, fa.apply)
|
||||
r := s.Router()
|
||||
|
||||
body := `{"ai_backend":"codex","marvin_cmd":"uv run am","knowledge_path":"/tmp/k.md"}`
|
||||
w := post(t, r, "/settings", body)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (body %s)", w.Code, w.Body.String())
|
||||
}
|
||||
if fa.called != 1 {
|
||||
t.Fatalf("applier called %d times, want 1", fa.called)
|
||||
}
|
||||
if fa.last.AIBackend != "codex" {
|
||||
t.Errorf("applied backend = %q, want codex", fa.last.AIBackend)
|
||||
}
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Errorf("settings file not written: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostSettingsInvalidBackendIs400AndNoSave(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
path := filepath.Join(t.TempDir(), "settings.json")
|
||||
fa := &fakeApplier{reject: true}
|
||||
s.SetSettings(path, settings.Settings{}, fa.apply)
|
||||
r := s.Router()
|
||||
|
||||
w := post(t, r, "/settings", `{"ai_backend":"bogus"}`)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", w.Code)
|
||||
}
|
||||
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||
t.Errorf("settings file should not exist after rejected save, stat err = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
|
||||
"antidrift/internal/domain"
|
||||
"antidrift/internal/session"
|
||||
"antidrift/internal/settings"
|
||||
"antidrift/internal/statemachine"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -29,6 +30,11 @@ type Server struct {
|
||||
|
||||
mu sync.Mutex
|
||||
timer *time.Timer
|
||||
|
||||
settingsMu sync.Mutex
|
||||
settings settings.Settings
|
||||
settingsPath string
|
||||
applyFn func(settings.Settings) error
|
||||
}
|
||||
|
||||
func NewServer(ctrl *session.Controller) *Server {
|
||||
@@ -69,6 +75,8 @@ func (s *Server) Router() *gin.Engine {
|
||||
r.POST("/refocus", s.handleRefocus)
|
||||
r.POST("/ontask", s.handleOnTask)
|
||||
r.POST("/knowledge/path", s.handleKnowledgePath)
|
||||
r.GET("/settings", s.handleGetSettings)
|
||||
r.POST("/settings", s.handlePostSettings)
|
||||
return r
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user