// 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, nil) 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() } }