feat(web): ambient line in state envelope + /ambient/snooze
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+33
-1
@@ -14,6 +14,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"keel/internal/harness"
|
"keel/internal/harness"
|
||||||
|
"keel/internal/mode"
|
||||||
"keel/internal/mode/focus/statemachine"
|
"keel/internal/mode/focus/statemachine"
|
||||||
"keel/internal/settings"
|
"keel/internal/settings"
|
||||||
|
|
||||||
@@ -28,6 +29,9 @@ type Server struct {
|
|||||||
h *harness.Harness
|
h *harness.Harness
|
||||||
bcast *Broadcaster
|
bcast *Broadcaster
|
||||||
|
|
||||||
|
ambientLine func() string
|
||||||
|
ambientSnooze func(time.Duration)
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
timer *time.Timer
|
timer *time.Timer
|
||||||
|
|
||||||
@@ -77,14 +81,42 @@ func (s *Server) Router() *gin.Engine {
|
|||||||
r.GET("/settings", s.handleGetSettings)
|
r.GET("/settings", s.handleGetSettings)
|
||||||
r.POST("/settings", s.handlePostSettings)
|
r.POST("/settings", s.handlePostSettings)
|
||||||
r.GET("/fs/browse", s.handleBrowse)
|
r.GET("/fs/browse", s.handleBrowse)
|
||||||
|
r.POST("/ambient/snooze", s.handleAmbientSnooze)
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) stateJSON() string {
|
func (s *Server) stateJSON() string {
|
||||||
data, _ := json.Marshal(s.h.State())
|
line := ""
|
||||||
|
if s.ambientLine != nil {
|
||||||
|
line = s.ambientLine()
|
||||||
|
}
|
||||||
|
payload := struct {
|
||||||
|
mode.Envelope
|
||||||
|
Ambient string `json:"ambient"`
|
||||||
|
}{Envelope: s.h.State(), Ambient: line}
|
||||||
|
data, _ := json.Marshal(payload)
|
||||||
return string(data)
|
return string(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetAmbient injects the ambient sentinel's line accessor and snooze action,
|
||||||
|
// wired by main. Called once at startup before serving.
|
||||||
|
func (s *Server) SetAmbient(line func() string, snooze func(time.Duration)) {
|
||||||
|
s.ambientLine = line
|
||||||
|
s.ambientSnooze = snooze
|
||||||
|
}
|
||||||
|
|
||||||
|
// Broadcast pushes the current state envelope to SSE subscribers. Exposed so the
|
||||||
|
// ambient sentinel's change signal can refresh the UI alongside harness changes.
|
||||||
|
func (s *Server) Broadcast() { s.broadcast() }
|
||||||
|
|
||||||
|
// handleAmbientSnooze mutes the ambient coach for an hour.
|
||||||
|
func (s *Server) handleAmbientSnooze(c *gin.Context) {
|
||||||
|
if s.ambientSnooze != nil {
|
||||||
|
s.ambientSnooze(time.Hour)
|
||||||
|
}
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) broadcast() {
|
func (s *Server) broadcast() {
|
||||||
s.bcast.Publish(s.stateJSON())
|
s.bcast.Publish(s.stateJSON())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -417,6 +417,34 @@ func TestReflectionFlowsToReviewThenPlanning(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestStateJSONIncludesAmbient(t *testing.T) {
|
||||||
|
h := harness.New(harness.Services{}, map[string]harness.Factory{})
|
||||||
|
s := NewServer(h)
|
||||||
|
s.SetAmbient(func() string { return "deep in YouTube" }, func(time.Duration) {})
|
||||||
|
js := s.stateJSON()
|
||||||
|
if !strings.Contains(js, `"ambient":"deep in YouTube"`) {
|
||||||
|
t.Fatalf("state JSON missing ambient field: %s", js)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAmbientSnoozeRoute(t *testing.T) {
|
||||||
|
h := harness.New(harness.Services{}, map[string]harness.Factory{})
|
||||||
|
s := NewServer(h)
|
||||||
|
var snoozed time.Duration
|
||||||
|
s.SetAmbient(func() string { return "" }, func(d time.Duration) { snoozed = d })
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/ambient/snooze", nil)
|
||||||
|
s.Router().ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusNoContent {
|
||||||
|
t.Fatalf("snooze status = %d, want 204", w.Code)
|
||||||
|
}
|
||||||
|
if snoozed != time.Hour {
|
||||||
|
t.Fatalf("snooze duration = %v, want 1h", snoozed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type stubJudge struct{ verdict ai.Verdict }
|
type stubJudge struct{ verdict ai.Verdict }
|
||||||
|
|
||||||
func (j stubJudge) JudgeDrift(ctx context.Context, commitment, class, title string) (ai.Verdict, error) {
|
func (j stubJudge) JudgeDrift(ctx context.Context, commitment, class, title string) (ai.Verdict, error) {
|
||||||
|
|||||||
Reference in New Issue
Block a user