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,69 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/andydixon/noema/pkg/events"
|
||||
)
|
||||
|
||||
func TestObservationAndSSEStream(t *testing.T) {
|
||||
srv, tok := testServer(t)
|
||||
do := func(method, path, body string) (*http.Response, error) {
|
||||
req, _ := http.NewRequest(method, srv.URL+path, strings.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer "+tok)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
return http.DefaultClient.Do(req)
|
||||
}
|
||||
if res, _ := do("POST", "/api/v1/minds", `{"slug":"sse"}`); res.StatusCode != 201 {
|
||||
t.Fatalf("create mind: %d", res.StatusCode)
|
||||
}
|
||||
// Open the stream first.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
req, _ := http.NewRequestWithContext(ctx, "GET", srv.URL+"/api/v1/minds/sse/events/stream?type=observation", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+tok)
|
||||
res, err := http.DefaultClient.Do(req)
|
||||
if err != nil || res.StatusCode != 200 || !strings.HasPrefix(res.Header.Get("Content-Type"), "text/event-stream") {
|
||||
t.Fatalf("stream open failed: %v %v", err, res)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if r, _ := do("POST", "/api/v1/minds/sse/observations", `{"text":"disk latency rising"}`); r.StatusCode != 201 {
|
||||
t.Fatalf("observation: %d", r.StatusCode)
|
||||
}
|
||||
if r, _ := do("POST", "/api/v1/minds/sse/observations", `{"text":""}`); r.StatusCode != 400 {
|
||||
t.Fatalf("empty observation should be 400, got %d", r.StatusCode)
|
||||
}
|
||||
sc := bufio.NewScanner(res.Body)
|
||||
var got events.Event
|
||||
for sc.Scan() {
|
||||
line := sc.Text()
|
||||
if strings.HasPrefix(line, "data: ") {
|
||||
if err := json.Unmarshal([]byte(line[6:]), &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if got.Type != events.Observation || got.Summary != "disk latency rising" || got.Seq == 0 {
|
||||
t.Fatalf("unexpected stream event: %+v", got)
|
||||
}
|
||||
var payload events.ObservationPayload
|
||||
json.Unmarshal(got.Payload, &payload) //nolint:errcheck
|
||||
if payload.Trust != events.TrustOperator || payload.Method != "api" {
|
||||
t.Fatalf("observation provenance missing: %+v", payload)
|
||||
}
|
||||
// Forensic context endpoint.
|
||||
r, _ := do("GET", "/api/v1/events/"+string(got.ID)+"/context", "")
|
||||
var c EventContext
|
||||
json.NewDecoder(r.Body).Decode(&c) //nolint:errcheck
|
||||
if c.Event.ID != got.ID || c.Children == nil {
|
||||
t.Fatalf("context wrong: %+v", c)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user