diff --git a/internal/memory/memory.go b/internal/memory/memory.go new file mode 100644 index 0000000..3b82f63 --- /dev/null +++ b/internal/memory/memory.go @@ -0,0 +1,70 @@ +// Package memory is Keel's durable, cross-run memory port. Modes Record derived +// events and read them back with Recent; the AW-backed adapter persists them to +// an ActivityWatch bucket. Event-kind names are owned by the modes that write +// them — this package stays a generic event store. +package memory + +import ( + "context" + "sync" + "time" +) + +// Event is one derived event Keel chooses to remember. Kind is the event type +// (e.g. "proposal_made"); Data carries pointers and small values, not copies of +// other tools' source of truth. +type Event struct { + Kind string + At time.Time + Data map[string]any +} + +// Store records derived events and reads recent ones back by kind. +type Store interface { + Record(ctx context.Context, e Event) error + Recent(ctx context.Context, kind string, n int) ([]Event, error) +} + +// nopStore is the no-memory fallback used when AW is unreachable or disabled. +type nopStore struct{} + +// NewNop returns a Store that drops writes and returns no history. +func NewNop() Store { return nopStore{} } + +func (nopStore) Record(context.Context, Event) error { return nil } +func (nopStore) Recent(context.Context, string, int) ([]Event, error) { return nil, nil } + +// Fake is an in-memory Store for tests. +type Fake struct { + mu sync.Mutex + events []Event +} + +// NewFake returns an empty in-memory Store. +func NewFake() *Fake { return &Fake{} } + +func (f *Fake) Record(_ context.Context, e Event) error { + f.mu.Lock() + f.events = append(f.events, e) + f.mu.Unlock() + return nil +} + +func (f *Fake) Recent(_ context.Context, kind string, n int) ([]Event, error) { + f.mu.Lock() + defer f.mu.Unlock() + var out []Event + for i := len(f.events) - 1; i >= 0 && len(out) < n; i-- { + if f.events[i].Kind == kind { + out = append(out, f.events[i]) + } + } + return out, nil +} + +// Events returns a snapshot of everything recorded, for test assertions. +func (f *Fake) Events() []Event { + f.mu.Lock() + defer f.mu.Unlock() + return append([]Event(nil), f.events...) +} diff --git a/internal/memory/memory_test.go b/internal/memory/memory_test.go new file mode 100644 index 0000000..2bc14c7 --- /dev/null +++ b/internal/memory/memory_test.go @@ -0,0 +1,46 @@ +package memory + +import ( + "context" + "testing" + "time" +) + +func TestFakeRecordThenRecentNewestFirstByKind(t *testing.T) { + f := NewFake() + ctx := context.Background() + _ = f.Record(ctx, Event{Kind: "proposal_made", At: time.Unix(1, 0), Data: map[string]any{"n": "a"}}) + _ = f.Record(ctx, Event{Kind: "action_taken", At: time.Unix(2, 0)}) + _ = f.Record(ctx, Event{Kind: "proposal_made", At: time.Unix(3, 0), Data: map[string]any{"n": "b"}}) + + got, err := f.Recent(ctx, "proposal_made", 5) + if err != nil { + t.Fatalf("Recent: %v", err) + } + if len(got) != 2 || got[0].Data["n"] != "b" { + t.Fatalf("want newest-first [b,a], got %+v", got) + } +} + +func TestFakeRecentRespectsLimit(t *testing.T) { + f := NewFake() + ctx := context.Background() + for i := 0; i < 4; i++ { + _ = f.Record(ctx, Event{Kind: "proposal_made", At: time.Unix(int64(i), 0)}) + } + got, _ := f.Recent(ctx, "proposal_made", 2) + if len(got) != 2 { + t.Fatalf("limit not honored: %d", len(got)) + } +} + +func TestNopStoreIsInert(t *testing.T) { + s := NewNop() + if err := s.Record(context.Background(), Event{Kind: "x"}); err != nil { + t.Fatalf("nop Record: %v", err) + } + got, err := s.Recent(context.Background(), "x", 5) + if err != nil || len(got) != 0 { + t.Fatalf("nop Recent = %+v, %v", got, err) + } +}