Files
so-many-crows/hub.go
T
SmokeyandClaude Opus 5.5 940c74672a Add web server, event stream, and Rive contract
- crows serve: HTTP API, Server-Sent Events per list, web approvals
  with a 10-minute fail-safe timeout; localhost only by default
- Shared pipeline for command line and server; trust.changed events
- EVENTS.md: server/UI contract; RIVE_CONTRACT.md: data-binding spec
  for the animator (one Crow artboard for v1)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UQnSw4RRWp8uqpj3nsnnXF
2026-10-27 08:31:27 -06:00

157 lines
3.6 KiB
Go

package main
import (
"encoding/json"
"sync"
"time"
)
// sseMsg is one event, already encoded, numbered within its list.
type sseMsg struct {
ID int
Name string
Data []byte
}
// listStream holds everything one list has said so far, plus who is listening.
type listStream struct {
requester string
created time.Time
finished bool
history []sseMsg
subs map[chan sseMsg]struct{}
}
// Hub fans events out to SSE subscribers per list_id and keeps each list's
// history in memory so late subscribers can catch up.
type Hub struct {
mu sync.Mutex
lists map[string]*listStream
}
func NewHub() *Hub {
return &Hub{lists: map[string]*listStream{}}
}
// subBuffer is how far a subscriber may fall behind before it is cut off.
// It reconnects with Last-Event-ID and catches up from history.
const subBuffer = 256
// keepLists caps how many lists the hub remembers; the oldest finished go first.
const keepLists = 200
func (h *Hub) Open(listID, requester string) {
h.mu.Lock()
defer h.mu.Unlock()
h.prune()
h.lists[listID] = &listStream{requester: requester, created: time.Now(), subs: map[chan sseMsg]struct{}{}}
}
func (h *Hub) prune() {
for len(h.lists) >= keepLists {
var oldest string
for id, ls := range h.lists {
if ls.finished && (oldest == "" || ls.created.Before(h.lists[oldest].created)) {
oldest = id
}
}
if oldest == "" {
return // every list is still flying; keep them all
}
h.drop(oldest)
}
}
func (h *Hub) drop(id string) {
for ch := range h.lists[id].subs {
close(ch)
}
delete(h.lists, id)
}
// ActiveLists returns the requester's lists that are still flying.
func (h *Hub) ActiveLists(requester string) []string {
h.mu.Lock()
defer h.mu.Unlock()
var ids []string
for id, ls := range h.lists {
if ls.requester == requester && !ls.finished {
ids = append(ids, id)
}
}
return ids
}
// Publish records an event for a list and sends it to current subscribers.
func (h *Hub) Publish(listID, name string, fields map[string]any) {
payload := map[string]any{}
for k, v := range fields {
payload[k] = v
}
payload["list_id"] = listID
payload["ts"] = time.Now().UTC().Format(time.RFC3339)
data, err := json.Marshal(payload)
if err != nil {
return
}
h.mu.Lock()
defer h.mu.Unlock()
ls := h.lists[listID]
if ls == nil {
return
}
m := sseMsg{ID: len(ls.history) + 1, Name: name, Data: data}
ls.history = append(ls.history, m)
if name == "list.finished" {
ls.finished = true
}
for ch := range ls.subs {
select {
case ch <- m:
default: // too slow: cut it off rather than stall the flock
delete(ls.subs, ch)
close(ch)
}
}
}
// Subscribe returns the history after lastID and a channel for what comes next.
// The channel closes if the subscriber falls too far behind.
func (h *Hub) Subscribe(listID string, lastID int) ([]sseMsg, chan sseMsg, bool) {
h.mu.Lock()
defer h.mu.Unlock()
ls := h.lists[listID]
if ls == nil {
return nil, nil, false
}
var past []sseMsg
if lastID < len(ls.history) {
past = append(past, ls.history[max(lastID, 0):]...)
}
ch := make(chan sseMsg, subBuffer)
ls.subs[ch] = struct{}{}
return past, ch, true
}
func (h *Hub) Unsubscribe(listID string, ch chan sseMsg) {
h.mu.Lock()
defer h.mu.Unlock()
if ls := h.lists[listID]; ls != nil {
if _, ok := ls.subs[ch]; ok {
delete(ls.subs, ch)
close(ch)
}
}
}
// Emitter binds the hub to one list.
func (h *Hub) Emitter(listID string) Emitter { return hubEmitter{h, listID} }
type hubEmitter struct {
h *Hub
id string
}
func (e hubEmitter) Emit(name string, fields map[string]any) { e.h.Publish(e.id, name, fields) }