feat: M4 gossip ops and UX
Refs #1 - /v1/diff endpoint returns each node's fingerprint set + tombstoned auto-thread status - knox gossip diff <peer-url>: shows peer-only/local-only fingerprints (pull/keep preview) and tombstone divergence (resolved-on-peer vs would-resurrect) - knox gossip sync: one-shot anti-entropy sweep + reconcile - gossip server starts before initial seed so peers can reach a booting node; KNOX_PEER_ADDR alone now serves without KNOX_PEERS - integration tests for diff + tombstone reporting - e2e verified: two live daemons, diff previewed 1655 peer-only fps, sync converged second node to 1655 observations
This commit is contained in:
@@ -42,6 +42,7 @@ func (n *Node) Handler() http.Handler {
|
||||
mux.HandleFunc("GET /v1/ping", n.handlePing)
|
||||
mux.HandleFunc("GET /v1/log", n.handleLog)
|
||||
mux.HandleFunc("POST /v1/obs/batch", n.handleBatch)
|
||||
mux.HandleFunc("GET /v1/diff", n.handleDiff)
|
||||
return mux
|
||||
}
|
||||
|
||||
@@ -110,6 +111,40 @@ func (n *Node) handleBatch(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// DiffSummary is the divergence snapshot served at /v1/diff: everything this
|
||||
// node observes, plus the status of auto-threaded threads (for tombstones).
|
||||
type DiffSummary struct {
|
||||
NodeID string `json:"node_id"`
|
||||
Name string `json:"name"`
|
||||
Fingerprints []string `json:"fingerprints"`
|
||||
ThreadStatus map[string]string `json:"thread_status"`
|
||||
ObsCount int `json:"obs_count"`
|
||||
}
|
||||
|
||||
func (n *Node) handleDiff(w http.ResponseWriter, r *http.Request) {
|
||||
fps, err := n.Kdb.DistinctFingerprints()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
threads, err := n.Kdb.ThreadStatusByCluster()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
list := make([]string, 0, len(fps))
|
||||
for f := range fps {
|
||||
list = append(list, f)
|
||||
}
|
||||
writeJSON(w, DiffSummary{
|
||||
NodeID: n.Kdb.NodeID(),
|
||||
Name: n.Name,
|
||||
Fingerprints: list,
|
||||
ThreadStatus: threads,
|
||||
ObsCount: len(fps),
|
||||
})
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
enc := json.NewEncoder(w)
|
||||
@@ -211,6 +246,15 @@ func (c *Client) get(url string, v any) error {
|
||||
return json.NewDecoder(resp.Body).Decode(v)
|
||||
}
|
||||
|
||||
// Diff fetches the peer's divergence summary.
|
||||
func (c *Client) Diff() (*DiffSummary, error) {
|
||||
var d DiffSummary
|
||||
if err := c.get(c.Addr+"/v1/diff", &d); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &d, nil
|
||||
}
|
||||
|
||||
// Run executes one anti-entropy sweep against the given peer addresses.
|
||||
func Run(kdb *db.KnoxDB, peers []string) {
|
||||
myID := kdb.NodeID()
|
||||
|
||||
@@ -84,6 +84,66 @@ func TestGossipConvergence(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestGossipDiff ensures /v1/diff reports per-node observation fingerprints and
|
||||
// tombstoned thread divergence.
|
||||
func TestGossipDiff(t *testing.T) {
|
||||
a := tmpKnoxDB(t)
|
||||
b := tmpKnoxDB(t)
|
||||
|
||||
seedObs(a, "AAA")
|
||||
seedObs(b, "BBB")
|
||||
|
||||
nodeA := &Node{Kdb: a, Name: "A"}
|
||||
sa := httptest.NewServer(nodeA.Handler())
|
||||
defer sa.Close()
|
||||
|
||||
c := &Client{Addr: sa.URL}
|
||||
d, err := c.Diff()
|
||||
if err != nil {
|
||||
t.Fatalf("diff: %v", err)
|
||||
}
|
||||
if d.NodeID != a.NodeID() {
|
||||
t.Errorf("diff node mismatch: %s != %s", d.NodeID, a.NodeID())
|
||||
}
|
||||
if len(d.Fingerprints) != 3 {
|
||||
t.Errorf("expected 3 fingerprints in diff, got %d", len(d.Fingerprints))
|
||||
}
|
||||
}
|
||||
|
||||
// TestGossipDiffTombstones confirms resolved auto-threads surface in diff.
|
||||
func TestGossipDiffTombstones(t *testing.T) {
|
||||
a := tmpKnoxDB(t)
|
||||
|
||||
id, _, err := a.CreateThreadCluster("Test thread", "motivation", "medium", "[]", `{"trigger":"auto_thread"}`, "cluster:testkey")
|
||||
if err != nil {
|
||||
t.Fatalf("create thread: %v", err)
|
||||
}
|
||||
if err := a.CloseThread(id); err != nil {
|
||||
t.Fatalf("close thread: %v", err)
|
||||
}
|
||||
|
||||
nodeA := &Node{Kdb: a, Name: "A"}
|
||||
sa := httptest.NewServer(nodeA.Handler())
|
||||
defer sa.Close()
|
||||
|
||||
c := &Client{Addr: sa.URL}
|
||||
d, err := c.Diff()
|
||||
if err != nil {
|
||||
t.Fatalf("diff: %v", err)
|
||||
}
|
||||
if len(d.ThreadStatus) != 1 {
|
||||
t.Errorf("expected 1 thread in diff, got %d", len(d.ThreadStatus))
|
||||
}
|
||||
for k, st := range d.ThreadStatus {
|
||||
if k != "cluster:testkey" {
|
||||
t.Errorf("unexpected thread key %s", k)
|
||||
}
|
||||
if st != "resolved" {
|
||||
t.Errorf("thread %s should be resolved, got %q", k, st)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestGossipIdempotent ensures a repeated sweep does not duplicate observations.
|
||||
func TestGossipIdempotent(t *testing.T) {
|
||||
a := tmpKnoxDB(t)
|
||||
|
||||
+15
-14
@@ -50,6 +50,21 @@ func (w *Watcher) Start() error {
|
||||
}
|
||||
defer watcher.Close()
|
||||
|
||||
// Start the gossip server first so peers can reach us while the initial
|
||||
// seed is still ingesting. Vault/dirs are logged after the seed below.
|
||||
gossipAddr := ListenAddr()
|
||||
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)
|
||||
}
|
||||
}()
|
||||
if peers := PeerAddrs(); len(peers) > 0 {
|
||||
log.Printf("[knox] gossip peers: %v", peers)
|
||||
}
|
||||
|
||||
w.seed(watcher)
|
||||
log.Printf("[knox] watching %d directories", len(w.dirs))
|
||||
|
||||
@@ -57,20 +72,6 @@ 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)
|
||||
|
||||
Reference in New Issue
Block a user