Files
Andy Dixon 85d769b245 feat: add immutable cognitive event store, live streams and event explorer
Phase 3. Events are the append-only record of everything a mind does
(ADR-0002). pkg/events defines the closed type vocabulary, categories,
evidence references and the observation payload with explicit trust
levels. The store validates and appends inside the caller's transaction,
publishes to a bounded fan-out bus only after commit, queries by seq
cursor with type/category/source/salience/confidence/time/search
filters, and walks provenance: cause, parents, children, cited evidence,
derived events and correlation groups. Durable per-consumer cursors never
move backwards.

Server-Sent Events streams (per mind and global) subscribe before
replaying history after Last-Event-ID so nothing is missed, apply the
same filter vocabulary live, and heartbeat. Operators can inject
observations tagged with operator trust for demos and tests.

UI: live thought stream with filters and paging, forensic event page
with causal chain and evidence navigation, dashboard important events.
Personality and status changes now leave operator_action events in the
mind's own history. CLI: event inspect/list/tail, observe.
2026-06-17 20:02:22 +01:00

92 lines
2.3 KiB
Go

// Package events stores cognitive events append-only and fans them out to
// live subscribers. Durable consumers read by sequence cursor; the in-memory
// bus is only a wake-up and a feed for the UI.
package events
import (
"context"
"sync"
"sync/atomic"
"github.com/andydixon/noema/pkg/events"
"github.com/andydixon/noema/pkg/ids"
)
// Bus fans events out to subscribers with bounded buffers. A slow subscriber
// loses events (counted in Dropped) rather than slowing the publisher; SSE
// clients recover via Last-Event-ID and durable consumers via cursors.
type Bus struct {
mu sync.RWMutex
subs map[uint64]*subscriber
nextID uint64
dropped atomic.Int64
max int
}
type subscriber struct {
mind ids.ID // Nil = all minds
ch chan events.Event
}
// NewBus creates a bus allowing at most max subscribers (T18).
func NewBus(max int) *Bus {
return &Bus{subs: map[uint64]*subscriber{}, max: max}
}
// ErrTooManySubscribers is returned when the subscriber cap is reached.
type ErrTooManySubscribers struct{}
func (ErrTooManySubscribers) Error() string { return "too many event subscribers" }
// Subscribe returns a channel of events for one mind (or all when mind is
// Nil). The subscription ends when ctx is cancelled.
func (b *Bus) Subscribe(ctx context.Context, mind ids.ID, buffer int) (<-chan events.Event, error) {
if buffer < 1 {
buffer = 64
}
b.mu.Lock()
if len(b.subs) >= b.max {
b.mu.Unlock()
return nil, ErrTooManySubscribers{}
}
id := b.nextID
b.nextID++
s := &subscriber{mind: mind, ch: make(chan events.Event, buffer)}
b.subs[id] = s
b.mu.Unlock()
context.AfterFunc(ctx, func() {
b.mu.Lock()
delete(b.subs, id)
b.mu.Unlock()
})
return s.ch, nil
}
// Publish delivers events to matching subscribers without blocking.
func (b *Bus) Publish(evs ...events.Event) {
b.mu.RLock()
defer b.mu.RUnlock()
for _, ev := range evs {
for _, s := range b.subs {
if !s.mind.IsZero() && s.mind != ev.MindID {
continue
}
select {
case s.ch <- ev:
default:
b.dropped.Add(1)
}
}
}
}
// Subscribers returns the current subscriber count.
func (b *Bus) Subscribers() int {
b.mu.RLock()
defer b.mu.RUnlock()
return len(b.subs)
}
// Dropped returns the number of events dropped for slow subscribers.
func (b *Bus) Dropped() int64 { return b.dropped.Load() }