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:
2026-08-29 05:08:45 -07:00
parent 8c054094a1
commit 48e9c39d7b
6 changed files with 273 additions and 17 deletions
+44
View File
@@ -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()