Files
antidrift/internal/web/web.go
T
felixm 7d69a1f320 Generalize the focus controller into a harness hosting swappable modes
Loosen AntiDrift's session controller into Keel's general collect→brain→act
loop. A new internal/harness runs at most one mode.Mode at a time, fanning
async completions out to the web SSE and status-bar surfaces.

- internal/mode: the Mode contract (Kind/Command/View/Active) plus optional
  EvidenceConsumer and Expirer ports, and the surfacing Envelope.
- internal/mode/focus: the former session/domain/statemachine packages moved
  under the mode, now satisfying the harness contracts unchanged.
- internal/mode/offscreen: a one-shot away-from-desk mode built on the new
  ai.Proposer, which turns a life-domain brief into one off-screen action.
- cmd/keeld replaces cmd/antidriftd; daemon wires focus + offscreen factories
  with per-mode persistence under ~/.keel/modes/<kind>.
- Finish the rename: KEEL_* env refs in the README, /keeld build artifact
  ignored, stale antidriftd binary removed.

Include the design + implementation plan this refactor was built from under
docs/superpowers/{specs,plans}/2026-06-04-controller-refactor*.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 18:00:49 -04:00

222 lines
5.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/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()
}