feat: M3 peer gossip protocol

Refs #1

- peers table (peer_id, addr, cursor, last_handshake)
- watch serves HTTP API: GET /v1/ping (knowledge vector),
  GET /v1/log?node=&after= (cursor-paged pull), POST /v1/obs/batch
- anti-entropy sweep (Run): ping, pull what we lack, push own obs;
  echo suppressed by node_id ownership; reconcile-on-pull
- config via KNOX_PEERS / KNOX_PEER_ADDR; knox gossip status
- db: GossipObservation wire type, PushObservations, ObservationsAfter,
  KnowledgeVector, peer upsert/list
- integration tests: bidirectional convergence + idempotency
This commit is contained in:
2026-08-29 04:53:26 -07:00
parent aa0dec68c1
commit 8c054094a1
10 changed files with 755 additions and 20 deletions
+57 -1
View File
@@ -2,6 +2,7 @@ package watch
import (
"log"
"net/http"
"os"
"path/filepath"
"strings"
@@ -12,7 +13,10 @@ import (
"github.com/fsnotify/fsnotify"
)
const browserInterval = 5 * time.Minute
const (
browserInterval = 5 * time.Minute
gossipInterval = 1 * time.Minute
)
type Watcher struct {
knoxDB *db.KnoxDB
@@ -53,15 +57,31 @@ func (w *Watcher) Start() error {
log.Printf("[knox] obsidian vault: %s", w.vault)
}
// Gossip: serve our observation log to peers and periodically converge.
gossipAddr := ListenAddr()
if peers := PeerAddrs(); len(peers) > 0 {
node := &Node{Kdb: w.knoxDB, Name: "knox", Addr: gossipAddr}
srv := &http.Server{Addr: gossipAddr, Handler: node.Handler()}
go func() {
log.Printf("[knox] gossip listening on %s", gossipAddr)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Printf("[knox] gossip server: %v", err)
}
}()
log.Printf("[knox] gossip peers: %v", peers)
}
debounceMap := make(map[string]time.Time)
browserTicker := time.NewTicker(browserInterval)
giteaTicker := time.NewTicker(10 * time.Minute)
gitTicker := time.NewTicker(10 * time.Minute)
threadTicker := time.NewTicker(10 * time.Minute)
gossipTicker := time.NewTicker(gossipInterval)
browserRunning := false
giteaRunning := false
gitRunning := false
threadRunning := false
gossipRunning := false
for {
select {
@@ -119,6 +139,15 @@ func (w *Watcher) Start() error {
defer func() { threadRunning = false }()
w.autoThread()
}()
case <-gossipTicker.C:
if gossipRunning {
continue
}
gossipRunning = true
go func() {
defer func() { gossipRunning = false }()
w.syncGossip()
}()
case err, ok := <-watcher.Errors:
if !ok {
@@ -404,6 +433,33 @@ func (w *Watcher) autoThread() {
}
}
// syncGossip runs one anti-entropy sweep against configured peers and rebuilds
// derived state if anything new arrived.
func (w *Watcher) syncGossip() {
peers := PeerAddrs()
if len(peers) == 0 {
return
}
before := 0
if n, err := w.knoxDB.EntryCount(); err == nil {
before = n
}
Run(w.knoxDB, peers)
// If new observations arrived, reconcile to pick up entries/threads they
// imply (deterministic log → derived rebuild).
if after, err := w.knoxDB.EntryCount(); err == nil && after > before {
created, linked, err := Reconcile(w.knoxDB)
if err != nil {
log.Printf("[knox] gossip reconcile: %v", err)
return
}
log.Printf("[knox] gossip reconcile done: %d created, %d linked", created, linked)
}
}
func (w *Watcher) isRelevantEvent(event fsnotify.Event) bool {
if event.Has(fsnotify.Remove) || event.Has(fsnotify.Rename) {
return false