// 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/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 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) return r } func (s *Server) stateJSON() string { data, _ := json.Marshal(s.h.State()) return string(data) } 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() }