fa178a1fd2
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
254 lines
6.7 KiB
Go
254 lines
6.7 KiB
Go
// Package web serves the local UI and an SSE state stream, and owns the
|
|
// server-authoritative timebox expiry timer.
|
|
package web
|
|
|
|
import (
|
|
"embed"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"net/http"
|
|
"sync"
|
|
"time"
|
|
|
|
"keel/internal/harness"
|
|
"keel/internal/mode"
|
|
"keel/internal/mode/focus/statemachine"
|
|
"keel/internal/settings"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
//go:embed static/*
|
|
var staticFS embed.FS
|
|
|
|
// Server wires the harness to HTTP and SSE.
|
|
type Server struct {
|
|
h *harness.Harness
|
|
bcast *Broadcaster
|
|
|
|
ambientLine func() string
|
|
ambientSnooze func(time.Duration)
|
|
|
|
mu sync.Mutex
|
|
timer *time.Timer
|
|
|
|
settingsMu sync.Mutex
|
|
settings settings.Settings
|
|
settingsPath string
|
|
applyFn func(settings.Settings) error
|
|
applyMu sync.Mutex // serializes POST /settings apply+save
|
|
}
|
|
|
|
// NewServer wires the harness to the SSE broadcaster: every harness change
|
|
// (mode start, command, async role completion, expiry) fans out the current
|
|
// state envelope to subscribers. The callback is registered additively, so the
|
|
// status-file writer can register its own listener alongside this one.
|
|
func NewServer(h *harness.Harness) *Server {
|
|
s := &Server{h: h, bcast: NewBroadcaster()}
|
|
h.AddOnChange(s.broadcast)
|
|
return s
|
|
}
|
|
|
|
func (s *Server) Router() *gin.Engine {
|
|
r := gin.New()
|
|
r.Use(gin.Recovery())
|
|
// keeld binds localhost only and sits behind no proxy, so trust no
|
|
// forwarded-IP headers. This also silences gin's trust-all-proxies warning.
|
|
_ = r.SetTrustedProxies(nil)
|
|
|
|
sub, _ := fs.Sub(staticFS, "static")
|
|
r.GET("/", func(c *gin.Context) {
|
|
c.FileFromFS("/", http.FS(sub))
|
|
})
|
|
r.GET("/app.css", func(c *gin.Context) {
|
|
c.FileFromFS("/app.css", http.FS(sub))
|
|
})
|
|
r.GET("/app.js", func(c *gin.Context) {
|
|
c.FileFromFS("/app.js", http.FS(sub))
|
|
})
|
|
r.GET("/favicon.ico", func(c *gin.Context) {
|
|
c.FileFromFS("/favicon.ico", http.FS(sub))
|
|
})
|
|
r.GET("/favicon.png", func(c *gin.Context) {
|
|
c.FileFromFS("/favicon.png", http.FS(sub))
|
|
})
|
|
r.GET("/events", s.handleEvents)
|
|
r.POST("/mode/:kind/start", s.handleStart)
|
|
r.POST("/mode/command/:name", s.handleCommand)
|
|
r.GET("/settings", s.handleGetSettings)
|
|
r.POST("/settings", s.handlePostSettings)
|
|
r.GET("/fs/browse", s.handleBrowse)
|
|
r.POST("/ambient/snooze", s.handleAmbientSnooze)
|
|
return r
|
|
}
|
|
|
|
func (s *Server) stateJSON() string {
|
|
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)
|
|
}
|
|
|
|
// 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() {
|
|
s.bcast.Publish(s.stateJSON())
|
|
}
|
|
|
|
// respond maps a harness/mode error to an HTTP status, otherwise returns the
|
|
// new state envelope. Broadcasting is handled solely by the harness onChange
|
|
// listeners registered in NewServer; callers must not broadcast here.
|
|
func (s *Server) respond(c *gin.Context, err error) {
|
|
if err != nil {
|
|
var illegal statemachine.IllegalTransitionError
|
|
switch {
|
|
case errors.As(err, &illegal):
|
|
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
|
case errors.Is(err, harness.ErrBusy):
|
|
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
|
case errors.Is(err, harness.ErrIdle):
|
|
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
|
case errors.Is(err, harness.ErrUnknownMode):
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
default:
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
}
|
|
return
|
|
}
|
|
c.Data(http.StatusOK, "application/json", []byte(s.stateJSON()))
|
|
}
|
|
|
|
// handleStart activates a mode by kind. Starting focus arms the expiry timer in
|
|
// case the mode is restored or transitions straight into a deadline-bearing
|
|
// state; the arm is a no-op when there is no deadline yet.
|
|
func (s *Server) handleStart(c *gin.Context) {
|
|
kind := c.Param("kind")
|
|
err := s.h.Start(kind)
|
|
if err == nil && kind == "focus" {
|
|
s.armExpiry()
|
|
}
|
|
s.respond(c, err)
|
|
}
|
|
|
|
// handleCommand routes a surface command to the active mode and manages the
|
|
// expiry timer for the focus lifecycle: a commitment arms it, completing or
|
|
// ending cancels it. The raw body is forwarded as the command payload; modes
|
|
// decode and validate it themselves.
|
|
func (s *Server) handleCommand(c *gin.Context) {
|
|
name := c.Param("name")
|
|
body, _ := io.ReadAll(c.Request.Body)
|
|
err := s.h.Command(c.Request.Context(), name, body)
|
|
switch name {
|
|
case "commitment":
|
|
if err == nil {
|
|
s.armExpiry()
|
|
}
|
|
case "complete", "end":
|
|
s.cancelExpiry()
|
|
}
|
|
s.respond(c, err)
|
|
}
|
|
|
|
func (s *Server) handleEvents(c *gin.Context) {
|
|
c.Header("Content-Type", "text/event-stream")
|
|
c.Header("Cache-Control", "no-cache")
|
|
c.Header("Connection", "keep-alive")
|
|
|
|
flusher, ok := c.Writer.(http.Flusher)
|
|
if !ok {
|
|
c.Status(http.StatusInternalServerError)
|
|
return
|
|
}
|
|
ch := s.bcast.Subscribe()
|
|
defer s.bcast.Unsubscribe(ch)
|
|
|
|
fmt.Fprintf(c.Writer, "data: %s\n\n", s.stateJSON())
|
|
flusher.Flush()
|
|
|
|
for {
|
|
select {
|
|
case <-c.Request.Context().Done():
|
|
return
|
|
case msg, open := <-ch:
|
|
if !open {
|
|
return
|
|
}
|
|
fmt.Fprintf(c.Writer, "data: %s\n\n", msg)
|
|
flusher.Flush()
|
|
}
|
|
}
|
|
}
|
|
|
|
// armExpiry schedules an Active -> Review transition at the active mode's
|
|
// deadline. A zero deadline (no deadline-bearing mode) is a no-op. On firing,
|
|
// Expire notifies harness listeners, which broadcasts the new state via the
|
|
// callback registered in NewServer.
|
|
func (s *Server) armExpiry() {
|
|
deadline := s.h.Deadline()
|
|
if deadline.IsZero() {
|
|
return
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.timer != nil {
|
|
s.timer.Stop()
|
|
}
|
|
d := time.Until(deadline)
|
|
if d < 0 {
|
|
d = 0
|
|
}
|
|
s.timer = time.AfterFunc(d, func() {
|
|
_ = s.h.Expire()
|
|
})
|
|
}
|
|
|
|
func (s *Server) cancelExpiry() {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.timer != nil {
|
|
s.timer.Stop()
|
|
s.timer = nil
|
|
}
|
|
}
|
|
|
|
// Init re-arms or expires a restored deadline-bearing session at startup. With
|
|
// no active mode the deadline is zero, so this is a no-op until a session is
|
|
// adopted.
|
|
func (s *Server) Init() {
|
|
deadline := s.h.Deadline()
|
|
if deadline.IsZero() {
|
|
return
|
|
}
|
|
if deadline.After(time.Now()) {
|
|
s.armExpiry()
|
|
return
|
|
}
|
|
_ = s.h.Expire()
|
|
}
|