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.
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
// 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() }
|
||||
@@ -0,0 +1,114 @@
|
||||
package events
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/andydixon/noema/internal/minds"
|
||||
"github.com/andydixon/noema/internal/storage"
|
||||
"github.com/andydixon/noema/migrations"
|
||||
"github.com/andydixon/noema/pkg/events"
|
||||
"github.com/andydixon/noema/pkg/ids"
|
||||
)
|
||||
|
||||
func TestBusBoundedFanout(t *testing.T) {
|
||||
b := NewBus(2)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
m1, m2 := ids.New(), ids.New()
|
||||
all, _ := b.Subscribe(ctx, ids.Nil, 1)
|
||||
only1, _ := b.Subscribe(ctx, m1, 8)
|
||||
if _, err := b.Subscribe(ctx, ids.Nil, 1); err == nil {
|
||||
t.Fatal("subscriber cap not enforced")
|
||||
}
|
||||
b.Publish(events.Event{MindID: m1, Type: events.Observation}, events.Event{MindID: m2, Type: events.Observation}, events.Event{MindID: m1, Type: events.Reflection})
|
||||
if len(only1) != 2 {
|
||||
t.Fatalf("mind filter wrong: %d", len(only1))
|
||||
}
|
||||
if len(all) != 1 || b.Dropped() != 2 {
|
||||
t.Fatalf("expected 1 buffered and 2 dropped, got %d / %d", len(all), b.Dropped())
|
||||
}
|
||||
cancel()
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
if b.Subscribers() != 0 {
|
||||
t.Fatal("subscribers not removed on cancel")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreAppendQueryImmutableAndCursors(t *testing.T) {
|
||||
pool := storage.TestPool(t, migrations.FS)
|
||||
ctx := context.Background()
|
||||
m, err := minds.NewStore(pool).Create(ctx, minds.CreateParams{Slug: "ev"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
other, _ := minds.NewStore(pool).Create(ctx, minds.CreateParams{Slug: "other"})
|
||||
s := NewStore(pool, NewBus(10), nil)
|
||||
sub, _ := s.Bus().Subscribe(ctx, m.ID, 16)
|
||||
|
||||
if _, err := s.Append(ctx, events.New{MindID: m.ID, Type: "bogus", Summary: "x"}); !errors.Is(err, ErrInvalid) {
|
||||
t.Fatalf("unknown type accepted: %v", err)
|
||||
}
|
||||
obs, err := s.Append(ctx, events.New{MindID: m.ID, Type: events.Observation, Source: "test", Summary: "load spike observed", Salience: 0.9, Confidence: 1.2, Payload: map[string]any{"cpu": 97}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if obs[0].Confidence != 1 || obs[0].Seq == 0 || obs[0].Category != events.CatPerception {
|
||||
t.Fatalf("normalisation or seq wrong: %+v", obs[0])
|
||||
}
|
||||
hyp, _ := s.Append(ctx, events.New{MindID: m.ID, Type: events.HypothesisCreated, Source: "reasoning", Summary: "disk contention hypothesis",
|
||||
CausationID: obs[0].ID, ParentIDs: []ids.ID{obs[0].ID}, CorrelationID: obs[0].ID, Confidence: 0.4, Salience: 0.7,
|
||||
Evidence: []events.EvidenceRef{{Kind: "event", ID: string(obs[0].ID)}}})
|
||||
s.Append(ctx, events.New{MindID: other.ID, Type: events.Observation, Source: "test", Summary: "other mind sees something else"}) //nolint:errcheck
|
||||
|
||||
// Live delivery.
|
||||
if len(sub) != 2 {
|
||||
t.Fatalf("expected 2 live events for mind, got %d", len(sub))
|
||||
}
|
||||
// Mind scoping and filters.
|
||||
got, _ := s.Query(ctx, Filter{MindID: m.ID})
|
||||
if len(got) != 2 || got[0].Type != events.HypothesisCreated {
|
||||
t.Fatalf("query wrong: %+v", got)
|
||||
}
|
||||
got, _ = s.Query(ctx, Filter{MindID: m.ID, Types: []events.Type{events.Observation}, MinSalience: 0.5})
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("type filter wrong: %d", len(got))
|
||||
}
|
||||
got, _ = s.Query(ctx, Filter{MindID: m.ID, Search: "contention"})
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("search wrong: %d", len(got))
|
||||
}
|
||||
got, _ = s.Query(ctx, Filter{MindID: m.ID, AfterSeq: obs[0].Seq, Ascending: true})
|
||||
if len(got) != 1 || got[0].ID != hyp[0].ID {
|
||||
t.Fatalf("cursor paging wrong: %+v", got)
|
||||
}
|
||||
// Causal navigation.
|
||||
kids, _ := s.Children(ctx, obs[0].ID, 10)
|
||||
if len(kids) != 1 || kids[0].ID != hyp[0].ID {
|
||||
t.Fatalf("children wrong: %+v", kids)
|
||||
}
|
||||
derived, _ := s.DerivedFrom(ctx, "event", string(obs[0].ID), 10)
|
||||
if len(derived) != 1 {
|
||||
t.Fatalf("evidence lookup wrong: %d", len(derived))
|
||||
}
|
||||
// Immutability.
|
||||
if _, err := pool.Exec(ctx, `UPDATE cognitive_events SET summary='rewritten' WHERE id=$1`, obs[0].ID); err == nil {
|
||||
t.Fatal("events must be append-only")
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `DELETE FROM cognitive_events`); err == nil {
|
||||
t.Fatal("events must not be deletable")
|
||||
}
|
||||
// Cursors never move backwards.
|
||||
s.SetCursor(ctx, pool, m.ID, "brain", hyp[0].Seq) //nolint:errcheck
|
||||
s.SetCursor(ctx, pool, m.ID, "brain", obs[0].Seq) //nolint:errcheck
|
||||
cur, _ := s.Cursor(ctx, pool, m.ID, "brain")
|
||||
if cur != hyp[0].Seq {
|
||||
t.Fatalf("cursor regressed: %d", cur)
|
||||
}
|
||||
st, _ := s.Stats(ctx, m.ID, time.Time{})
|
||||
if st.Total != 2 || st.ByCategory["belief"] != 1 {
|
||||
t.Fatalf("stats wrong: %+v", st)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package events
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/andydixon/noema/pkg/events"
|
||||
"github.com/andydixon/noema/pkg/ids"
|
||||
)
|
||||
|
||||
// OperatorAction records an administrative change as a cognitive event so the
|
||||
// mind's history shows when and why it was altered from outside.
|
||||
func (s *Store) OperatorAction(ctx context.Context, mind ids.ID, actor, action, summary string, details map[string]any) {
|
||||
if details == nil {
|
||||
details = map[string]any{}
|
||||
}
|
||||
details["action"], details["actor"] = action, actor
|
||||
s.Append(ctx, events.New{MindID: mind, Type: events.OperatorAction, Source: "operator", Summary: summary, Salience: 0.6, Confidence: 1, //nolint:errcheck
|
||||
Metadata: map[string]any{"action": action, "actor": actor}, Payload: details})
|
||||
}
|
||||
|
||||
// Matches applies the parts of a Filter that can be evaluated in memory, for
|
||||
// live streams.
|
||||
func (f Filter) Matches(ev events.Event) bool {
|
||||
if !f.MindID.IsZero() && ev.MindID != f.MindID {
|
||||
return false
|
||||
}
|
||||
if len(f.Types) > 0 && !containsType(f.Types, ev.Type) {
|
||||
return false
|
||||
}
|
||||
if len(f.Categories) > 0 && !containsCat(f.Categories, ev.Category) {
|
||||
return false
|
||||
}
|
||||
if len(f.Sources) > 0 && !containsStr(f.Sources, ev.Source) {
|
||||
return false
|
||||
}
|
||||
if ev.Salience < f.MinSalience || ev.Confidence < f.MinConfidence {
|
||||
return false
|
||||
}
|
||||
if f.MaxConfidence > 0 && ev.Confidence > f.MaxConfidence {
|
||||
return false
|
||||
}
|
||||
if !f.CorrelationID.IsZero() && ev.CorrelationID != f.CorrelationID {
|
||||
return false
|
||||
}
|
||||
if q := strings.TrimSpace(f.Search); q != "" && !strings.Contains(strings.ToLower(ev.Summary), strings.ToLower(q)) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func containsType(xs []events.Type, x events.Type) bool {
|
||||
for _, y := range xs {
|
||||
if y == x {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func containsCat(xs []events.Category, x events.Category) bool {
|
||||
for _, y := range xs {
|
||||
if y == x {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func containsStr(xs []string, x string) bool {
|
||||
for _, y := range xs {
|
||||
if y == x {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,434 @@
|
||||
package events
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"github.com/andydixon/noema/internal/observability"
|
||||
"github.com/andydixon/noema/internal/storage"
|
||||
"github.com/andydixon/noema/pkg/events"
|
||||
"github.com/andydixon/noema/pkg/ids"
|
||||
)
|
||||
|
||||
// Store appends and queries cognitive events.
|
||||
type Store struct {
|
||||
db *pgxpool.Pool
|
||||
bus *Bus
|
||||
metrics *observability.Metrics
|
||||
}
|
||||
|
||||
// NewStore creates the store. bus and metrics may be nil in tests.
|
||||
func NewStore(db *pgxpool.Pool, bus *Bus, metrics *observability.Metrics) *Store {
|
||||
if bus == nil {
|
||||
bus = NewBus(1000)
|
||||
}
|
||||
return &Store{db: db, bus: bus, metrics: metrics}
|
||||
}
|
||||
|
||||
// Bus exposes the fan-out bus for subscribers.
|
||||
func (s *Store) Bus() *Bus { return s.bus }
|
||||
|
||||
// ErrInvalid marks malformed events.
|
||||
var ErrInvalid = errors.New("invalid event")
|
||||
|
||||
func validate(n *events.New) error {
|
||||
if n.MindID.IsZero() {
|
||||
return fmt.Errorf("%w: mind id required", ErrInvalid)
|
||||
}
|
||||
if !n.Type.Valid() {
|
||||
return fmt.Errorf("%w: unknown type %q", ErrInvalid, n.Type)
|
||||
}
|
||||
if strings.TrimSpace(n.Summary) == "" {
|
||||
return fmt.Errorf("%w: summary required", ErrInvalid)
|
||||
}
|
||||
if n.Source == "" {
|
||||
n.Source = "unknown"
|
||||
}
|
||||
if len(n.Summary) > 2000 {
|
||||
n.Summary = n.Summary[:2000]
|
||||
}
|
||||
n.Salience = clamp(n.Salience, 0, 1)
|
||||
n.Confidence = clamp(n.Confidence, 0, 1)
|
||||
if n.Valence != nil {
|
||||
v := clamp(*n.Valence, -1, 1)
|
||||
n.Valence = &v
|
||||
}
|
||||
if n.Arousal != nil {
|
||||
a := clamp(*n.Arousal, 0, 1)
|
||||
n.Arousal = &a
|
||||
}
|
||||
if n.OccurredAt.IsZero() {
|
||||
n.OccurredAt = time.Now()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func clamp(v, lo, hi float64) float64 {
|
||||
if v != v { // NaN
|
||||
return lo
|
||||
}
|
||||
if v < lo {
|
||||
return lo
|
||||
}
|
||||
if v > hi {
|
||||
return hi
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// Append writes events in one transaction and publishes them after commit.
|
||||
func (s *Store) Append(ctx context.Context, news ...events.New) ([]events.Event, error) {
|
||||
var out []events.Event
|
||||
err := storage.Tx(ctx, s.db, func(tx pgx.Tx) error {
|
||||
var err error
|
||||
out, err = s.AppendIn(ctx, tx, news...)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.Publish(out...)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// AppendIn writes events inside the caller's transaction. The caller must call
|
||||
// Publish after committing so subscribers never see uncommitted events.
|
||||
func (s *Store) AppendIn(ctx context.Context, q storage.Querier, news ...events.New) ([]events.Event, error) {
|
||||
out := make([]events.Event, 0, len(news))
|
||||
for i := range news {
|
||||
n := &news[i]
|
||||
if err := validate(n); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
payload, err := json.Marshal(n.Payload)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: payload: %v", ErrInvalid, err)
|
||||
}
|
||||
if n.Payload == nil {
|
||||
payload = []byte("{}")
|
||||
}
|
||||
if n.Evidence == nil {
|
||||
n.Evidence = []events.EvidenceRef{}
|
||||
}
|
||||
if n.Metadata == nil {
|
||||
n.Metadata = map[string]any{}
|
||||
}
|
||||
evidence, _ := json.Marshal(n.Evidence)
|
||||
metadata, _ := json.Marshal(n.Metadata)
|
||||
if n.ParentIDs == nil {
|
||||
n.ParentIDs = []ids.ID{}
|
||||
}
|
||||
ev := events.Event{ID: ids.New(), MindID: n.MindID, Type: n.Type, Category: events.CategoryOf(n.Type), Source: n.Source,
|
||||
OccurredAt: n.OccurredAt, ParentIDs: n.ParentIDs, CausationID: n.CausationID, CorrelationID: n.CorrelationID,
|
||||
Salience: n.Salience, Confidence: n.Confidence, Valence: n.Valence, Arousal: n.Arousal, Evidence: n.Evidence,
|
||||
Summary: n.Summary, Metadata: n.Metadata, Payload: payload}
|
||||
err = q.QueryRow(ctx, `INSERT INTO cognitive_events (id, mind_id, type, category, source, occurred_at, parent_ids, causation_id, correlation_id,
|
||||
salience, confidence, valence, arousal, evidence, summary, metadata, payload)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17) RETURNING seq, recorded_at`,
|
||||
ev.ID, ev.MindID, string(ev.Type), string(ev.Category), ev.Source, ev.OccurredAt, uuidArray(ev.ParentIDs), nullID(ev.CausationID), nullID(ev.CorrelationID),
|
||||
ev.Salience, ev.Confidence, ev.Valence, ev.Arousal, evidence, ev.Summary, metadata, payload).Scan(&ev.Seq, &ev.RecordedAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("append event: %w", err)
|
||||
}
|
||||
out = append(out, ev)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Publish fans committed events out to live subscribers and metrics.
|
||||
func (s *Store) Publish(evs ...events.Event) {
|
||||
if s.metrics != nil {
|
||||
for _, ev := range evs {
|
||||
s.metrics.Events.WithLabelValues(string(ev.Type)).Inc()
|
||||
}
|
||||
}
|
||||
s.bus.Publish(evs...)
|
||||
}
|
||||
|
||||
func uuidArray(in []ids.ID) []string {
|
||||
out := make([]string, len(in))
|
||||
for i, id := range in {
|
||||
out[i] = string(id)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func nullID(id ids.ID) *string {
|
||||
if id.IsZero() {
|
||||
return nil
|
||||
}
|
||||
s := string(id)
|
||||
return &s
|
||||
}
|
||||
|
||||
// Filter selects events. Zero values mean "no constraint".
|
||||
type Filter struct {
|
||||
MindID ids.ID
|
||||
Types []events.Type
|
||||
Categories []events.Category
|
||||
Sources []string
|
||||
MinSalience float64
|
||||
MinConfidence float64
|
||||
MaxConfidence float64 // 0 = unset
|
||||
Since, Until time.Time
|
||||
AfterSeq int64 // exclusive lower bound (forward paging / cursors)
|
||||
BeforeSeq int64 // exclusive upper bound (backward paging)
|
||||
CorrelationID ids.ID
|
||||
Search string // substring of summary, case-insensitive
|
||||
Limit int
|
||||
Ascending bool
|
||||
}
|
||||
|
||||
const cols = `id, seq, mind_id, type, category, source, occurred_at, recorded_at, parent_ids, causation_id, correlation_id, salience, confidence, valence, arousal, evidence, summary, metadata, payload`
|
||||
|
||||
// Query returns events matching f ordered by seq (descending unless Ascending).
|
||||
func (s *Store) Query(ctx context.Context, f Filter) ([]events.Event, error) {
|
||||
var where []string
|
||||
var args []any
|
||||
arg := func(v any) string { args = append(args, v); return fmt.Sprintf("$%d", len(args)) }
|
||||
if !f.MindID.IsZero() {
|
||||
where = append(where, "mind_id = "+arg(f.MindID))
|
||||
}
|
||||
if len(f.Types) > 0 {
|
||||
ts := make([]string, len(f.Types))
|
||||
for i, t := range f.Types {
|
||||
ts[i] = string(t)
|
||||
}
|
||||
where = append(where, "type = ANY("+arg(ts)+")")
|
||||
}
|
||||
if len(f.Categories) > 0 {
|
||||
cs := make([]string, len(f.Categories))
|
||||
for i, c := range f.Categories {
|
||||
cs[i] = string(c)
|
||||
}
|
||||
where = append(where, "category = ANY("+arg(cs)+")")
|
||||
}
|
||||
if len(f.Sources) > 0 {
|
||||
where = append(where, "source = ANY("+arg(f.Sources)+")")
|
||||
}
|
||||
if f.MinSalience > 0 {
|
||||
where = append(where, "salience >= "+arg(f.MinSalience))
|
||||
}
|
||||
if f.MinConfidence > 0 {
|
||||
where = append(where, "confidence >= "+arg(f.MinConfidence))
|
||||
}
|
||||
if f.MaxConfidence > 0 {
|
||||
where = append(where, "confidence <= "+arg(f.MaxConfidence))
|
||||
}
|
||||
if !f.Since.IsZero() {
|
||||
where = append(where, "occurred_at >= "+arg(f.Since))
|
||||
}
|
||||
if !f.Until.IsZero() {
|
||||
where = append(where, "occurred_at <= "+arg(f.Until))
|
||||
}
|
||||
if f.AfterSeq > 0 {
|
||||
where = append(where, "seq > "+arg(f.AfterSeq))
|
||||
}
|
||||
if f.BeforeSeq > 0 {
|
||||
where = append(where, "seq < "+arg(f.BeforeSeq))
|
||||
}
|
||||
if !f.CorrelationID.IsZero() {
|
||||
where = append(where, "correlation_id = "+arg(f.CorrelationID))
|
||||
}
|
||||
if q := strings.TrimSpace(f.Search); q != "" {
|
||||
where = append(where, "summary ILIKE "+arg("%"+escapeLike(q)+"%"))
|
||||
}
|
||||
limit := f.Limit
|
||||
if limit <= 0 || limit > 1000 {
|
||||
limit = 100
|
||||
}
|
||||
order := "DESC"
|
||||
if f.Ascending {
|
||||
order = "ASC"
|
||||
}
|
||||
sql := "SELECT " + cols + " FROM cognitive_events"
|
||||
if len(where) > 0 {
|
||||
sql += " WHERE " + strings.Join(where, " AND ")
|
||||
}
|
||||
sql += " ORDER BY seq " + order + " LIMIT " + arg(limit)
|
||||
rows, err := s.db.Query(ctx, sql, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []events.Event
|
||||
for rows.Next() {
|
||||
ev, err := scan(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, ev)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func escapeLike(s string) string {
|
||||
r := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`)
|
||||
return r.Replace(s)
|
||||
}
|
||||
|
||||
// Get fetches one event by id.
|
||||
func (s *Store) Get(ctx context.Context, id ids.ID) (events.Event, error) {
|
||||
ev, err := scan(s.db.QueryRow(ctx, "SELECT "+cols+" FROM cognitive_events WHERE id=$1", id))
|
||||
return ev, storage.Translate(err)
|
||||
}
|
||||
|
||||
// GetMany fetches events by id, preserving no particular order.
|
||||
func (s *Store) GetMany(ctx context.Context, idList []ids.ID) ([]events.Event, error) {
|
||||
if len(idList) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
rows, err := s.db.Query(ctx, "SELECT "+cols+" FROM cognitive_events WHERE id = ANY($1) ORDER BY seq", uuidArray(idList))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []events.Event
|
||||
for rows.Next() {
|
||||
ev, err := scan(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, ev)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// Children returns events directly caused by, or listing as parent, the given event.
|
||||
func (s *Store) Children(ctx context.Context, id ids.ID, limit int) ([]events.Event, error) {
|
||||
if limit <= 0 || limit > 500 {
|
||||
limit = 100
|
||||
}
|
||||
rows, err := s.db.Query(ctx, "SELECT "+cols+" FROM cognitive_events WHERE causation_id=$1 OR $1 = ANY(parent_ids) ORDER BY seq LIMIT $2", id, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []events.Event
|
||||
for rows.Next() {
|
||||
ev, err := scan(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, ev)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// DerivedFrom returns events that cite id as evidence.
|
||||
func (s *Store) DerivedFrom(ctx context.Context, kind string, id string, limit int) ([]events.Event, error) {
|
||||
if limit <= 0 || limit > 500 {
|
||||
limit = 100
|
||||
}
|
||||
ref, _ := json.Marshal([]map[string]string{{"kind": kind, "id": id}})
|
||||
rows, err := s.db.Query(ctx, "SELECT "+cols+" FROM cognitive_events WHERE evidence @> $1::jsonb ORDER BY seq LIMIT $2", ref, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []events.Event
|
||||
for rows.Next() {
|
||||
ev, err := scan(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, ev)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func scan(row pgx.Row) (events.Event, error) {
|
||||
var ev events.Event
|
||||
var typ, cat string
|
||||
var parents []string
|
||||
var causation, correlation *string
|
||||
var evidence, metadata []byte
|
||||
err := row.Scan(&ev.ID, &ev.Seq, &ev.MindID, &typ, &cat, &ev.Source, &ev.OccurredAt, &ev.RecordedAt, &parents, &causation, &correlation,
|
||||
&ev.Salience, &ev.Confidence, &ev.Valence, &ev.Arousal, &evidence, &ev.Summary, &metadata, &ev.Payload)
|
||||
if err != nil {
|
||||
return ev, err
|
||||
}
|
||||
ev.Type, ev.Category = events.Type(typ), events.Category(cat)
|
||||
ev.ParentIDs = make([]ids.ID, len(parents))
|
||||
for i, p := range parents {
|
||||
ev.ParentIDs[i] = ids.ID(p)
|
||||
}
|
||||
if causation != nil {
|
||||
ev.CausationID = ids.ID(*causation)
|
||||
}
|
||||
if correlation != nil {
|
||||
ev.CorrelationID = ids.ID(*correlation)
|
||||
}
|
||||
json.Unmarshal(evidence, &ev.Evidence) //nolint:errcheck
|
||||
json.Unmarshal(metadata, &ev.Metadata) //nolint:errcheck
|
||||
if ev.Evidence == nil {
|
||||
ev.Evidence = []events.EvidenceRef{}
|
||||
}
|
||||
if ev.Metadata == nil {
|
||||
ev.Metadata = map[string]any{}
|
||||
}
|
||||
return ev, nil
|
||||
}
|
||||
|
||||
// Cursor returns the last sequence consumed by consumer for a mind.
|
||||
func (s *Store) Cursor(ctx context.Context, q storage.Querier, mind ids.ID, consumer string) (int64, error) {
|
||||
var seq int64
|
||||
err := q.QueryRow(ctx, `SELECT last_seq FROM mind_cursors WHERE mind_id=$1 AND consumer=$2`, mind, consumer).Scan(&seq)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return 0, nil
|
||||
}
|
||||
return seq, err
|
||||
}
|
||||
|
||||
// SetCursor advances a consumer cursor (never backwards).
|
||||
func (s *Store) SetCursor(ctx context.Context, q storage.Querier, mind ids.ID, consumer string, seq int64) error {
|
||||
_, err := q.Exec(ctx, `INSERT INTO mind_cursors (mind_id, consumer, last_seq) VALUES ($1,$2,$3)
|
||||
ON CONFLICT (mind_id, consumer) DO UPDATE SET last_seq = GREATEST(mind_cursors.last_seq, excluded.last_seq), updated_at = now()`, mind, consumer, seq)
|
||||
return err
|
||||
}
|
||||
|
||||
// Stats summarises a mind's events for overview pages.
|
||||
type Stats struct {
|
||||
Total int64 `json:"total"`
|
||||
ByCategory map[string]int64 `json:"by_category"`
|
||||
LastSeq int64 `json:"last_seq"`
|
||||
LastAt *time.Time `json:"last_at,omitempty"`
|
||||
}
|
||||
|
||||
// Stats counts events for a mind (optionally since a time).
|
||||
func (s *Store) Stats(ctx context.Context, mind ids.ID, since time.Time) (Stats, error) {
|
||||
st := Stats{ByCategory: map[string]int64{}}
|
||||
rows, err := s.db.Query(ctx, `SELECT category, count(*), max(seq), max(occurred_at) FROM cognitive_events WHERE mind_id=$1 AND ($2::timestamptz IS NULL OR occurred_at >= $2) GROUP BY category`, mind, nullTime(since))
|
||||
if err != nil {
|
||||
return st, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var cat string
|
||||
var n, maxSeq int64
|
||||
var at *time.Time
|
||||
if err := rows.Scan(&cat, &n, &maxSeq, &at); err != nil {
|
||||
return st, err
|
||||
}
|
||||
st.ByCategory[cat] = n
|
||||
st.Total += n
|
||||
if maxSeq > st.LastSeq {
|
||||
st.LastSeq, st.LastAt = maxSeq, at
|
||||
}
|
||||
}
|
||||
return st, rows.Err()
|
||||
}
|
||||
|
||||
func nullTime(t time.Time) *time.Time {
|
||||
if t.IsZero() {
|
||||
return nil
|
||||
}
|
||||
return &t
|
||||
}
|
||||
Reference in New Issue
Block a user