4f6b26a220
antidriftd binds localhost only and sits behind no proxy, so trusting all proxies (gin's default) is both wrong and a startup warning. Set an empty trusted-proxy list to disable forwarded-IP header trust and silence it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
253 lines
5.9 KiB
Go
253 lines
5.9 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/domain"
|
|
"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())
|
|
// antidriftd 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("/planning", s.handlePlanning)
|
|
r.POST("/coach", s.handleCoach)
|
|
r.POST("/commitment", s.handleCommitment)
|
|
r.POST("/complete", s.handleComplete)
|
|
r.POST("/end", s.handleEnd)
|
|
r.POST("/refocus", s.handleRefocus)
|
|
r.POST("/ontask", s.handleOnTask)
|
|
r.POST("/knowledge/path", s.handleKnowledgePath)
|
|
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"`
|
|
AllowedWindowClasses []string `json:"allowed_window_classes"`
|
|
Enforce bool `json:"enforce"`
|
|
}
|
|
|
|
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
|
|
}
|
|
level := domain.EnforcementWarn
|
|
if req.Enforce {
|
|
level = domain.EnforcementBlock
|
|
}
|
|
err := s.ctrl.StartManualCommitment(req.NextAction, req.SuccessCondition, time.Duration(req.TimeboxSecs)*time.Second, req.AllowedWindowClasses, level)
|
|
if err == nil {
|
|
s.armExpiry()
|
|
}
|
|
s.respond(c, err)
|
|
}
|
|
|
|
type knowledgePathRequest struct {
|
|
Path string `json:"path"`
|
|
}
|
|
|
|
// handleKnowledgePath repoints the profile file at runtime (session-only). It
|
|
// mutates config, not commitment state, so it never returns a transition error;
|
|
// it just sets the path, re-loads, and broadcasts the refreshed state.
|
|
func (s *Server) handleKnowledgePath(c *gin.Context) {
|
|
var req knowledgePathRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json"})
|
|
return
|
|
}
|
|
s.ctrl.SetKnowledgePath(req.Path)
|
|
s.broadcast()
|
|
c.Data(http.StatusOK, "application/json", []byte(s.stateJSON()))
|
|
}
|
|
|
|
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) handleRefocus(c *gin.Context) {
|
|
s.respond(c, s.ctrl.Refocus())
|
|
}
|
|
|
|
func (s *Server) handleOnTask(c *gin.Context) {
|
|
s.respond(c, s.ctrl.OnTask())
|
|
}
|
|
|
|
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()
|
|
}
|
|
}
|