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.
92 lines
2.3 KiB
Go
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() }
|