Files
antidrift/internal/web/web.go
T
2026-05-31 14:19:12 -04:00

202 lines
4.2 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/fs"
"net/http"
"sync"
"time"
"antidrift/internal/session"
"antidrift/internal/statemachine"
"github.com/gin-gonic/gin"
)
//go:embed static/*
var staticFS embed.FS
// Server wires the session controller to HTTP and SSE.
type Server struct {
ctrl *session.Controller
bcast *Broadcaster
mu sync.Mutex
timer *time.Timer
}
func NewServer(ctrl *session.Controller) *Server {
s := &Server{ctrl: ctrl, bcast: NewBroadcaster()}
ctrl.SetOnChange(s.broadcast)
return s
}
func (s *Server) Router() *gin.Engine {
r := gin.New()
r.Use(gin.Recovery())
sub, _ := fs.Sub(staticFS, "static")
r.GET("/", func(c *gin.Context) {
c.FileFromFS("/", http.FS(sub))
})
r.GET("/events", s.handleEvents)
r.POST("/planning", s.handlePlanning)
r.POST("/coach", s.handleCoach)
r.POST("/commitment", s.handleCommitment)
r.POST("/complete", s.handleComplete)
r.POST("/end", s.handleEnd)
return r
}
func (s *Server) stateJSON() string {
data, _ := json.Marshal(s.ctrl.State())
return string(data)
}
func (s *Server) broadcast() {
s.bcast.Publish(s.stateJSON())
}
// respond maps a controller error to an HTTP status, otherwise broadcasts and
// returns the new state.
func (s *Server) respond(c *gin.Context, err error) {
if err != nil {
var illegal statemachine.IllegalTransitionError
if errors.As(err, &illegal) {
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
s.broadcast()
c.Data(http.StatusOK, "application/json", []byte(s.stateJSON()))
}
func (s *Server) handlePlanning(c *gin.Context) {
s.cancelExpiry()
s.respond(c, s.ctrl.EnterPlanning())
}
type coachRequest struct {
Intent string `json:"intent"`
}
func (s *Server) handleCoach(c *gin.Context) {
var req coachRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json"})
return
}
s.respond(c, s.ctrl.RequestCoach(req.Intent))
}
type commitmentRequest struct {
NextAction string `json:"next_action"`
SuccessCondition string `json:"success_condition"`
TimeboxSecs int64 `json:"timebox_secs"`
}
func (s *Server) handleCommitment(c *gin.Context) {
var req commitmentRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json"})
return
}
err := s.ctrl.StartManualCommitment(req.NextAction, req.SuccessCondition, time.Duration(req.TimeboxSecs)*time.Second)
if err == nil {
s.armExpiry()
}
s.respond(c, err)
}
func (s *Server) handleComplete(c *gin.Context) {
s.cancelExpiry()
s.respond(c, s.ctrl.Complete())
}
func (s *Server) handleEnd(c *gin.Context) {
s.respond(c, s.ctrl.End())
}
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 commitment deadline.
func (s *Server) armExpiry() {
deadline := s.ctrl.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() {
if err := s.ctrl.Expire(); err == nil {
s.broadcast()
}
})
}
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 Active session at startup.
func (s *Server) Init() {
st := s.ctrl.State()
if st.RuntimeState != "active" {
return
}
if s.ctrl.Deadline().After(time.Now()) {
s.armExpiry()
return
}
if err := s.ctrl.Expire(); err == nil {
s.broadcast()
}
}