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
This commit is contained in:
12 files changed
+1320
-129
No files matched your search
@@ -0,0 +1,156 @@
|
||||
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) }
|
||||
Reference in new issue
Block a user