Add feed curator to daemon for activity feed curation (gt-38doh)
Implements the feed daemon goroutine that: - Tails ~/gt/.events.jsonl for raw events - Filters by visibility tag (keeps feed/both, drops audit) - Deduplicates repeated done events from same actor - Aggregates sling events when multiple occur in window - Writes curated events to ~/gt/.feed.jsonl with summaries The curator runs as a goroutine in the gt daemon, starting when the daemon starts and stopping gracefully on shutdown. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
196
internal/feed/curator_test.go
Normal file
196
internal/feed/curator_test.go
Normal file
@@ -0,0 +1,196 @@
|
||||
package feed
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/steveyegge/gastown/internal/events"
|
||||
)
|
||||
|
||||
func TestCurator_FiltersByVisibility(t *testing.T) {
|
||||
// Create temp directory
|
||||
tmpDir, err := os.MkdirTemp("", "feed-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("creating temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
// Create events file with test events
|
||||
eventsPath := filepath.Join(tmpDir, events.EventsFile)
|
||||
feedPath := filepath.Join(tmpDir, FeedFile)
|
||||
|
||||
// Write a feed-visible event
|
||||
feedEvent := events.Event{
|
||||
Timestamp: time.Now().UTC().Format(time.RFC3339),
|
||||
Source: "gt",
|
||||
Type: events.TypeSling,
|
||||
Actor: "mayor",
|
||||
Payload: map[string]interface{}{"bead": "gt-123", "target": "gastown/slit"},
|
||||
Visibility: events.VisibilityFeed,
|
||||
}
|
||||
feedData, _ := json.Marshal(feedEvent)
|
||||
|
||||
// Write an audit-only event (should be filtered out)
|
||||
auditEvent := events.Event{
|
||||
Timestamp: time.Now().UTC().Format(time.RFC3339),
|
||||
Source: "gt",
|
||||
Type: "internal_check",
|
||||
Actor: "daemon",
|
||||
Visibility: events.VisibilityAudit,
|
||||
}
|
||||
auditData, _ := json.Marshal(auditEvent)
|
||||
|
||||
// Create events file
|
||||
if err := os.WriteFile(eventsPath, []byte{}, 0644); err != nil {
|
||||
t.Fatalf("creating events file: %v", err)
|
||||
}
|
||||
|
||||
// Start curator
|
||||
curator := NewCurator(tmpDir)
|
||||
if err := curator.Start(); err != nil {
|
||||
t.Fatalf("starting curator: %v", err)
|
||||
}
|
||||
defer curator.Stop()
|
||||
|
||||
// Give curator time to start
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
// Append events
|
||||
f, err := os.OpenFile(eventsPath, os.O_APPEND|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
t.Fatalf("opening events file: %v", err)
|
||||
}
|
||||
f.Write(append(feedData, '\n'))
|
||||
f.Write(append(auditData, '\n'))
|
||||
f.Close()
|
||||
|
||||
// Wait for processing
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
|
||||
// Check feed file
|
||||
feedContent, err := os.ReadFile(feedPath)
|
||||
if err != nil {
|
||||
t.Fatalf("reading feed file: %v", err)
|
||||
}
|
||||
|
||||
// Should contain feed event but not audit event
|
||||
if len(feedContent) == 0 {
|
||||
t.Error("feed file is empty, expected at least one event")
|
||||
}
|
||||
|
||||
var writtenEvent FeedEvent
|
||||
if err := json.Unmarshal(feedContent[:len(feedContent)-1], &writtenEvent); err != nil {
|
||||
t.Fatalf("parsing feed event: %v", err)
|
||||
}
|
||||
|
||||
if writtenEvent.Type != events.TypeSling {
|
||||
t.Errorf("expected type %s, got %s", events.TypeSling, writtenEvent.Type)
|
||||
}
|
||||
if writtenEvent.Actor != "mayor" {
|
||||
t.Errorf("expected actor 'mayor', got %s", writtenEvent.Actor)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurator_DedupesDoneEvents(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "feed-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("creating temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
eventsPath := filepath.Join(tmpDir, events.EventsFile)
|
||||
feedPath := filepath.Join(tmpDir, FeedFile)
|
||||
|
||||
// Create events file
|
||||
if err := os.WriteFile(eventsPath, []byte{}, 0644); err != nil {
|
||||
t.Fatalf("creating events file: %v", err)
|
||||
}
|
||||
|
||||
// Start curator
|
||||
curator := NewCurator(tmpDir)
|
||||
if err := curator.Start(); err != nil {
|
||||
t.Fatalf("starting curator: %v", err)
|
||||
}
|
||||
defer curator.Stop()
|
||||
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
// Write 3 identical done events from same actor
|
||||
f, _ := os.OpenFile(eventsPath, os.O_APPEND|os.O_WRONLY, 0644)
|
||||
for i := 0; i < 3; i++ {
|
||||
doneEvent := events.Event{
|
||||
Timestamp: time.Now().UTC().Format(time.RFC3339),
|
||||
Source: "gt",
|
||||
Type: events.TypeDone,
|
||||
Actor: "gastown/slit",
|
||||
Payload: map[string]interface{}{"bead": "slit-12345"},
|
||||
Visibility: events.VisibilityFeed,
|
||||
}
|
||||
data, _ := json.Marshal(doneEvent)
|
||||
f.Write(append(data, '\n'))
|
||||
}
|
||||
f.Close()
|
||||
|
||||
// Wait for processing
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
|
||||
// Count feed events
|
||||
feedContent, _ := os.ReadFile(feedPath)
|
||||
lines := 0
|
||||
for _, b := range feedContent {
|
||||
if b == '\n' {
|
||||
lines++
|
||||
}
|
||||
}
|
||||
|
||||
// Should only have 1 event due to deduplication
|
||||
if lines != 1 {
|
||||
t.Errorf("expected 1 feed event after deduplication, got %d", lines)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurator_GeneratesSummary(t *testing.T) {
|
||||
tmpDir, _ := os.MkdirTemp("", "feed-test-*")
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
curator := NewCurator(tmpDir)
|
||||
|
||||
tests := []struct {
|
||||
event *events.Event
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
event: &events.Event{
|
||||
Type: events.TypeSling,
|
||||
Actor: "mayor",
|
||||
Payload: map[string]interface{}{"bead": "gt-123", "target": "gastown/slit"},
|
||||
},
|
||||
expected: "mayor assigned gt-123 to gastown/slit",
|
||||
},
|
||||
{
|
||||
event: &events.Event{
|
||||
Type: events.TypeDone,
|
||||
Actor: "gastown/slit",
|
||||
Payload: map[string]interface{}{"bead": "slit-12345"},
|
||||
},
|
||||
expected: "gastown/slit completed work on slit-12345",
|
||||
},
|
||||
{
|
||||
event: &events.Event{
|
||||
Type: events.TypeHandoff,
|
||||
Actor: "gastown/witness",
|
||||
},
|
||||
expected: "gastown/witness handed off to fresh session",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
summary := curator.generateSummary(tc.event)
|
||||
if summary != tc.expected {
|
||||
t.Errorf("generateSummary(%s): expected %q, got %q", tc.event.Type, tc.expected, summary)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user