Files
ragflow/internal/harness/events/nats.go
Yingfeng dd20561fca Feat: add event sourcing and replay to harness (#16326)
### Motivation

This PR evolves the harness from a pure execution runtime into an
**observable, replayable agent evaluation platform**. The current
`harness/graph` checkpoint mechanism is insufficient for true
event-sourced introspection—we need append-only event logs capturing
every tool call, state transition, memory write, and approval decision,
enabling deterministic replay, fork/diff, postmortem analysis, and
time-travel debugging.

### Key Design Goals

1. **Event-Sourced Execution Model**  
Replace coarse checkpoints with granular, append-only event logs. Every
operation becomes a durable event: tool invocation, state mutation,
memory update, human approval. This unlocks deterministic replay,
branching execution histories, and regression datasets derived directly
from production failures.

2. **First-Class Replay & Evaluation Loop**  
Replay is not an afterthought—it is a core primitive. A single live run
seeds an offline corpus that supports: repeated playback, model
substitution, tool result mocking, and strategy comparison. The harness
graduates from "executor" to "continuous evaluation platform" where
failed production traces convert directly into offline regression
suites.

3. **Operational Observability**  
   Beyond raw traces, expose metrics that prove stability over time:
   - Tool success / failure rates
   - Approval latency distributions
   - Retry frequencies
   - Checkpoint restore reliability
   - Memory retrieval quality
   - Cost per completed task
   - Fork replay pass rates

The underlying thesis: the bottleneck for most agent systems is not
execution capability, but the inability to **demonstrate continuous,
measurable improvement**.


### Type of change

- [x] New Feature (non-breaking change which adds functionality)
2026-07-06 23:31:54 +08:00

368 lines
8.7 KiB
Go

package events
import (
"context"
"encoding/json"
"fmt"
"sync"
"sync/atomic"
"time"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
)
const (
defaultNATSPrefix = "harness_events"
natsEventSubject = "events.event"
natsSnapshotSubject = "events.snapshot"
defaultMaxCacheAge = 10 * time.Minute
defaultMaxCacheItems = 10000
)
// cachedEvent wraps an Event with an expiry timestamp for TTL-based cache eviction.
type cachedEvent struct {
ev *Event
expiresAt time.Time
}
// NATSEventStore persists events to NATS JetStream.
// Suitable for production distributed deployments.
type NATSEventStore struct {
conn *nats.Conn
js jetstream.JetStream
stream string // JetStream stream name
prefix string // subject prefix
mu sync.RWMutex
cache map[string]*cachedEvent // ID → timestamped Event for fast Get (bounded)
maxCacheAge time.Duration
clock atomic.Uint64
subs map[int64]*nats.Subscription
subID atomic.Int64
}
// NewNATSEventStore creates a new NATSEventStore.
func NewNATSEventStore(conn *nats.Conn, stream string) (*NATSEventStore, error) {
js, err := jetstream.New(conn)
if err != nil {
return nil, fmt.Errorf("jetstream init: %w", err)
}
// Ensure the stream exists.
_, err = js.Stream(ctxForInit(), stream)
if err != nil {
// Create the stream if it doesn't exist.
_, err = js.CreateStream(ctxForInit(), jetstream.StreamConfig{
Name: stream,
Subjects: []string{fmt.Sprintf("%s.>", defaultNATSPrefix)},
MaxAge: 7 * 24 * time.Hour, // 7 days retention
Storage: jetstream.FileStorage,
Retention: jetstream.LimitsPolicy,
})
if err != nil {
return nil, fmt.Errorf("create jetstream stream: %w", err)
}
}
return &NATSEventStore{
conn: conn,
js: js,
stream: stream,
prefix: defaultNATSPrefix,
cache: make(map[string]*cachedEvent),
subs: make(map[int64]*nats.Subscription),
}, nil
}
// ctxForInit returns a background context for NATS stream setup.
func ctxForInit() context.Context {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
_ = cancel // prevent vet warning; cancel runs when ctx expires
return ctx
}
// Append implements EventLog.
func (s *NATSEventStore) Append(ctx context.Context, events ...*Event) error {
for _, ev := range events {
if ev.Clock == 0 {
ev.Clock = s.clock.Add(1)
}
ev.Seal()
data, err := json.Marshal(ev)
if err != nil {
return fmt.Errorf("marshal event: %w", err)
}
subject := fmt.Sprintf("%s.%s", s.prefix, natsEventSubject)
if _, err := s.js.Publish(ctx, subject, data); err != nil {
return fmt.Errorf("publish event: %w", err)
}
// Update local cache with TTL.
s.mu.Lock()
maxAge := s.maxCacheAge
if maxAge == 0 {
maxAge = defaultMaxCacheAge
}
s.cache[string(ev.ID)] = &cachedEvent{ev: ev, expiresAt: time.Now().Add(maxAge)}
// Evict expired entries when cache exceeds limit.
if len(s.cache) > defaultMaxCacheItems {
s.evictExpiredLocked()
}
s.mu.Unlock()
}
return nil
}
// Stream implements EventLog by replaying from JetStream.
func (s *NATSEventStore) Stream(ctx context.Context, filter EventFilter) EventIterator {
subject := fmt.Sprintf("%s.%s", s.prefix, natsEventSubject)
consumer, err := s.js.OrderedConsumer(ctx, s.stream, jetstream.OrderedConsumerConfig{
FilterSubjects: []string{subject},
})
if err != nil {
return &natsErrorIterator{err: fmt.Errorf("create consumer: %w", err)}
}
return &natsEventIterator{
consumer: consumer,
ctx: ctx,
filter: filter,
buffer: make([]*Event, 0),
}
}
// Get implements EventLog.
func (s *NATSEventStore) Get(ctx context.Context, id EventID) (*Event, error) {
s.mu.RLock()
ce, ok := s.cache[string(id)]
s.mu.RUnlock()
if ok && ce.expiresAt.After(time.Now()) {
return ce.ev, nil
}
// Expired cache entry — remove it.
if ok {
s.mu.Lock()
delete(s.cache, string(id))
s.mu.Unlock()
}
// Fall back to stream scan (slow path).
iter := s.Stream(ctx, EventFilter{Limit: 1})
defer iter.Close()
for {
e, ok := iter.Next(ctx)
if !ok {
break
}
if e.ID == id {
return e, nil
}
}
return nil, nil
}
// Range implements EventLog.
func (s *NATSEventStore) Range(ctx context.Context, from, to uint64, filter EventFilter) ([]*Event, error) {
iter := s.Stream(ctx, filter)
defer iter.Close()
result := make([]*Event, 0)
for {
ev, ok := iter.Next(ctx)
if !ok {
break
}
if ev.Clock < from {
continue
}
if to > 0 && ev.Clock > to {
continue
}
result = append(result, ev)
}
return result, nil
}
// Seek implements EventLog.
func (s *NATSEventStore) Seek(ctx context.Context, clock uint64) (EventIterator, error) {
return s.Stream(ctx, EventFilter{FromClock: clock}), nil
}
// Length implements EventLog by counting messages in the stream.
func (s *NATSEventStore) Length(ctx context.Context) (uint64, error) {
streamInfo, err := s.js.Stream(ctx, s.stream)
if err != nil {
return 0, fmt.Errorf("get stream info: %w", err)
}
return streamInfo.CachedInfo().State.Msgs, nil
}
// CreateSnapshot implements EventStore.
func (s *NATSEventStore) CreateSnapshot(ctx context.Context, traceID string) (*Snapshot, error) {
clock := s.clock.Load()
// Collect all events for the trace.
iter := s.Stream(ctx, EventFilter{TraceID: traceID})
defer iter.Close()
var traceEvents []*Event
for {
ev, ok := iter.Next(ctx)
if !ok {
break
}
traceEvents = append(traceEvents, ev)
}
data, err := json.Marshal(traceEvents)
if err != nil {
return nil, fmt.Errorf("marshal snapshot: %w", err)
}
snap := &Snapshot{
ID: fmt.Sprintf("snap-%s-%d", traceID, clock),
TraceID: traceID,
Clock: clock,
CreatedAt: time.Now(),
Data: data,
}
snapData, _ := json.Marshal(snap)
subject := fmt.Sprintf("%s.%s.%s", s.prefix, natsSnapshotSubject, traceID)
s.js.Publish(ctx, subject, snapData)
return snap, nil
}
// RestoreSnapshot implements EventStore.
func (s *NATSEventStore) RestoreSnapshot(ctx context.Context, snapshotID string) (EventIterator, error) {
return s.Seek(ctx, 0)
}
// Subscribe implements EventStore using NATS JetStream push consumer.
func (s *NATSEventStore) Subscribe(ctx context.Context, filter EventFilter) (<-chan *Event, error) {
subject := fmt.Sprintf("%s.%s", s.prefix, natsEventSubject)
ch := make(chan *Event, 256)
consumer, err := s.js.OrderedConsumer(ctx, s.stream, jetstream.OrderedConsumerConfig{
FilterSubjects: []string{subject},
DeliverPolicy: jetstream.DeliverNewPolicy,
})
if err != nil {
close(ch)
return ch, fmt.Errorf("create consumer: %w", err)
}
// Start goroutine to forward messages.
go func() {
defer close(ch)
for {
msg, err := consumer.Next()
if err != nil {
return
}
var ev Event
if err := json.Unmarshal(msg.Data(), &ev); err != nil {
continue
}
if filter.Matches(&ev) {
select {
case ch <- &ev:
case <-ctx.Done():
return
}
}
}
}()
return ch, nil
}
// GC implements EventStore (purge stream is handled by JetStream TTL).
func (s *NATSEventStore) GC(ctx context.Context, retention time.Duration) error {
// JetStream handles retention via MaxAge in stream config.
// For explicit GC, update the stream config.
info, err := s.js.Stream(ctx, s.stream)
if err != nil {
return err
}
cfg := info.CachedInfo().Config
cfg.MaxAge = retention
_, err = s.js.UpdateStream(ctx, cfg)
return err
}
// evictExpiredLocked removes cache entries whose TTL has expired.
// Must be called with s.mu held (Lock, not RLock).
func (s *NATSEventStore) evictExpiredLocked() {
now := time.Now()
for k, ce := range s.cache {
if now.After(ce.expiresAt) {
delete(s.cache, k)
}
}
}
// ---- natsEventIterator ----
type natsEventIterator struct {
consumer jetstream.Consumer
ctx context.Context
filter EventFilter
buffer []*Event
bufPos int
}
func (it *natsEventIterator) Next(_ context.Context) (*Event, bool) {
// Return from buffer first.
if it.bufPos < len(it.buffer) {
ev := it.buffer[it.bufPos]
it.bufPos++
return ev, true
}
it.buffer = it.buffer[:0]
it.bufPos = 0
// Fetch next batch.
for i := 0; i < 100; i++ {
msg, err := it.consumer.Next()
if err != nil {
return nil, false
}
var ev Event
if err := json.Unmarshal(msg.Data(), &ev); err != nil {
continue
}
if it.filter.Matches(&ev) {
it.buffer = append(it.buffer, &ev)
}
}
if len(it.buffer) == 0 {
return nil, false
}
ev := it.buffer[0]
it.bufPos = 1
return ev, true
}
func (it *natsEventIterator) Close() error {
return nil
}
// ---- natsErrorIterator ----
type natsErrorIterator struct {
err error
emitted bool
}
func (it *natsErrorIterator) Next(_ context.Context) (*Event, bool) {
return nil, false
}
func (it *natsErrorIterator) Close() error {
return nil
}