Add web server, SSE handlers, and UI
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>AntiDrift</title>
|
||||
<style>
|
||||
:root { color-scheme: dark; }
|
||||
body { margin: 0; font: 16px/1.5 system-ui, sans-serif; background: #11131a; color: #e6e8ee; }
|
||||
main { max-width: 640px; margin: 8vh auto; padding: 0 24px; }
|
||||
h1 { font-size: 14px; letter-spacing: .2em; text-transform: uppercase; color: #7a8095; }
|
||||
.card { background: #1a1d27; border: 1px solid #272b38; border-radius: 14px; padding: 28px; }
|
||||
label { display: block; font-size: 13px; color: #9aa0b4; margin: 16px 0 6px; }
|
||||
input { width: 100%; box-sizing: border-box; padding: 10px 12px; background: #11131a;
|
||||
border: 1px solid #2d3242; border-radius: 8px; color: #e6e8ee; font: inherit; }
|
||||
button { margin-top: 22px; padding: 11px 18px; border: 0; border-radius: 8px;
|
||||
background: #4c6ef5; color: #fff; font: inherit; font-weight: 600; cursor: pointer; }
|
||||
button:disabled { background: #2d3242; color: #6a7186; cursor: not-allowed; }
|
||||
.timer { font-size: 56px; font-weight: 700; font-variant-numeric: tabular-nums; margin: 8px 0 4px; }
|
||||
.action { font-size: 22px; font-weight: 600; }
|
||||
.meta { color: #9aa0b4; }
|
||||
.pill { display: inline-block; font-size: 12px; letter-spacing: .15em; text-transform: uppercase;
|
||||
color: #7a8095; border: 1px solid #272b38; border-radius: 999px; padding: 3px 10px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>AntiDrift</h1>
|
||||
<div class="card" id="view">connecting…</div>
|
||||
</main>
|
||||
<script>
|
||||
const view = document.getElementById('view');
|
||||
let countdownTimer = null;
|
||||
|
||||
function post(path, body) {
|
||||
return fetch(path, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function fmt(secs) {
|
||||
secs = Math.max(0, Math.floor(secs));
|
||||
const m = String(Math.floor(secs / 60)).padStart(2, '0');
|
||||
const s = String(secs % 60).padStart(2, '0');
|
||||
return `${m}:${s}`;
|
||||
}
|
||||
|
||||
function render(state) {
|
||||
if (countdownTimer) { clearInterval(countdownTimer); countdownTimer = null; }
|
||||
const rs = state.runtime_state;
|
||||
if (rs === 'locked') {
|
||||
view.innerHTML = `<span class="pill">Locked</span>
|
||||
<p class="meta">No active commitment.</p>
|
||||
<button id="plan">Start planning</button>`;
|
||||
document.getElementById('plan').onclick = () => post('/planning');
|
||||
} else if (rs === 'planning') {
|
||||
view.innerHTML = `<span class="pill">Planning</span>
|
||||
<label>Next action</label><input id="na" placeholder="What exactly will you do?">
|
||||
<label>Success condition</label><input id="sc" placeholder="How do you know it's done?">
|
||||
<label>Minutes</label><input id="mins" type="number" min="1" value="25">
|
||||
<button id="start" disabled>Start commitment</button>`;
|
||||
const na = document.getElementById('na'), sc = document.getElementById('sc'),
|
||||
mins = document.getElementById('mins'), start = document.getElementById('start');
|
||||
const check = () => { start.disabled = !(na.value.trim() && sc.value.trim() && +mins.value > 0); };
|
||||
[na, sc, mins].forEach(el => el.oninput = check);
|
||||
start.onclick = () => post('/commitment', {
|
||||
next_action: na.value.trim(),
|
||||
success_condition: sc.value.trim(),
|
||||
timebox_secs: Math.round(+mins.value * 60),
|
||||
});
|
||||
} else if (rs === 'active') {
|
||||
const c = state.commitment || {};
|
||||
view.innerHTML = `<span class="pill">Active</span>
|
||||
<div class="timer" id="t">--:--</div>
|
||||
<div class="action">${c.next_action || ''}</div>
|
||||
<p class="meta">Done when: ${c.success_condition || ''}</p>
|
||||
<button id="done">Complete</button>`;
|
||||
document.getElementById('done').onclick = () => post('/complete');
|
||||
const t = document.getElementById('t');
|
||||
const tick = () => { t.textContent = fmt((c.deadline_unix_secs || 0) - Date.now() / 1000); };
|
||||
tick();
|
||||
countdownTimer = setInterval(tick, 250);
|
||||
} else if (rs === 'review') {
|
||||
const c = state.commitment || {};
|
||||
view.innerHTML = `<span class="pill">Review</span>
|
||||
<p class="action">Session ended</p>
|
||||
<p class="meta">${c.next_action || ''}</p>
|
||||
<button id="end">End</button>`;
|
||||
document.getElementById('end').onclick = () => post('/end');
|
||||
} else {
|
||||
view.textContent = rs;
|
||||
}
|
||||
}
|
||||
|
||||
const es = new EventSource('/events');
|
||||
es.onmessage = (e) => render(JSON.parse(e.data));
|
||||
es.onerror = () => { view.textContent = 'disconnected — is antidriftd running?'; };
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,185 @@
|
||||
// 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 {
|
||||
return &Server{ctrl: ctrl, bcast: NewBroadcaster()}
|
||||
}
|
||||
|
||||
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("/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 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.Complete(); 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.Complete(); err == nil {
|
||||
s.broadcast()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"antidrift/internal/domain"
|
||||
"antidrift/internal/session"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func newTestServer(t *testing.T) *Server {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
path := filepath.Join(t.TempDir(), "state.json")
|
||||
ctrl, err := session.New(path)
|
||||
if err != nil {
|
||||
t.Fatalf("controller: %v", err)
|
||||
}
|
||||
return NewServer(ctrl)
|
||||
}
|
||||
|
||||
func post(t *testing.T, r http.Handler, path, body string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
return w
|
||||
}
|
||||
|
||||
func TestPlanningThenCommitmentReachesActive(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
r := s.Router()
|
||||
|
||||
if w := post(t, r, "/planning", ""); w.Code != http.StatusOK {
|
||||
t.Fatalf("/planning code %d", w.Code)
|
||||
}
|
||||
body := `{"next_action":"Build web","success_condition":"web tests pass","timebox_secs":1500}`
|
||||
if w := post(t, r, "/commitment", body); w.Code != http.StatusOK {
|
||||
t.Fatalf("/commitment code %d body %s", w.Code, w.Body.String())
|
||||
}
|
||||
if s.ctrl.State().RuntimeState != domain.RuntimeActive {
|
||||
t.Fatalf("expected Active, got %s", s.ctrl.State().RuntimeState)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommitmentRejectsInvalidInput(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
r := s.Router()
|
||||
_ = post(t, r, "/planning", "")
|
||||
body := `{"next_action":"","success_condition":"x","timebox_secs":1500}`
|
||||
w := post(t, r, "/commitment", body)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIllegalTransitionReturns409(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
r := s.Router()
|
||||
// /complete from Locked is illegal.
|
||||
w := post(t, r, "/complete", "")
|
||||
if w.Code != http.StatusConflict {
|
||||
t.Fatalf("expected 409, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user