- 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
157 lines
3.6 KiB
Go
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) }
|