Files
knox/internal/cmd/gossip.go
david 52c4d1f1e6 fix: derived-state determinism, error visibility, metrics naming, HTTP hardening
- rebuild: fold observations per fingerprint in a total order
  (hcl DESC, node_id DESC, id DESC) so two nodes with identical logs
  rebuild identical entries (was: arbitrary bare-column row, merge-order
  dependent)
- threads: CreateThreadCluster uses INSERT OR IGNORE + existing-id
  fallback — concurrent auto-threaders converge instead of hitting UNIQUE
- errors surfaced instead of swallowed: scanEntries returns rows.Err(),
  Stats() fails fast on query errors, AutoLinkThreadObservations /
  linkTemporalNeighbors / golden-thread linking propagate failures,
  AddThreadNote + LinkObservationToThread write under one tx,
  watch records session upsert failures
- gossip client: push checks HTTP status and reports errors (a broken
  push direction no longer looks like a silent success); gossip diff
  gets a 10s timeout so a dead peer cannot hang the CLI
- ingest: failed source sweeps (obsidian/browser/gitea) are logged,
  and -d's help text now states its file-only scope
- watch --quiet: fatal errors go to stderr instead of io.Discard
- main: cobra SilenceErrors/SilenceUsage (errors print once, usage is
  not dumped on runtime failures); knox mcp exits 0 on SIGINT/SIGTERM
- metrics: drop _total suffix from gauges (knox_observations,
  knox_entries, knox_projects, knox_sessions, knox_peers, knox_threads);
  _total stays on counters per Prometheus convention
- http: ReadHeaderTimeout + IdleTimeout on gossip, metrics, and web servers

tests: concurrent cluster-create idempotency, HCL-order rebuild fold
(both merge orders), push HTTP-error surfacing; full suite + -race pass,
gofmt clean
2026-09-17 02:28:08 -07:00

159 lines
4.2 KiB
Go

package cmd
import (
"fmt"
"time"
"github.com/david/knox/internal/db"
"github.com/david/knox/internal/watch"
"github.com/spf13/cobra"
)
// NewGossipCmd exposes peer status/diff for the gossip protocol.
func NewGossipCmd(kdb *db.KnoxDB) *cobra.Command {
cmd := &cobra.Command{
Use: "gossip",
Short: "Inspect peer-to-peer observation sync",
}
cmd.AddCommand(
&cobra.Command{
Use: "status",
Short: "Show known peers and knowledge vectors",
RunE: func(c *cobra.Command, args []string) error {
peers, err := kdb.ListPeers()
if err != nil {
return err
}
vector, err := kdb.KnowledgeVector()
if err != nil {
return err
}
fmt.Printf("node_id: %s\n", kdb.NodeID())
fmt.Printf("peers (static: %d configured):\n", len(watch.PeerAddrs()))
for _, p := range peers {
fmt.Printf(" %-24s %-28s last=%s vector=%d\n", p.PeerID, p.Addr, p.LastHandshake, p.Cursor)
}
if len(peers) == 0 {
fmt.Println(" (none — set KNOX_PEERS to sync with other nodes)")
}
fmt.Println("knowledge vector (node_id -> max hcl):")
for nid, hcl := range vector {
if nid == kdb.NodeID() {
continue
}
fmt.Printf(" %-24s -> %d\n", nid, hcl)
}
return nil
},
},
&cobra.Command{
Use: "diff <peer-url>",
Short: "Show observation/thread divergence with a peer",
Args: cobra.ExactArgs(1),
RunE: func(c *cobra.Command, args []string) error {
return gossipDiff(kdb, args[0])
},
},
&cobra.Command{
Use: "sync",
Short: "Run one anti-entropy sweep against configured peers",
RunE: func(c *cobra.Command, args []string) error {
peers := watch.PeerAddrs()
if len(peers) == 0 {
return fmt.Errorf("no peers configured (set KNOX_PEERS)")
}
watch.Run(kdb, nil, peers)
created, linked, err := watch.Reconcile(kdb)
if err != nil {
return err
}
fmt.Printf("sync done: %d threads created, %d observations linked\n", created, linked)
return nil
},
},
)
return cmd
}
// gossipDiff compares this node's observation log + auto-threads with a peer's
// via /v1/diff. Never writes; it is the preview for what a sync would adopt.
func gossipDiff(kdb *db.KnoxDB, peerAddr string) error {
c := &watch.Client{Addr: peerAddr, Timeout: 10 * time.Second}
remote, err := c.Diff()
if err != nil {
return err
}
local, err := kdb.DistinctFingerprints()
if err != nil {
return err
}
remoteFP := make(map[string]bool, len(remote.Fingerprints))
for _, f := range remote.Fingerprints {
remoteFP[f] = true
}
var peerOnly, localOnly []string
for f := range remoteFP {
if !local[f] {
peerOnly = append(peerOnly, f)
}
}
for f := range local {
if !remoteFP[f] {
localOnly = append(localOnly, f)
}
}
fmt.Printf("peer: %s (%s)\n", remote.Name, remote.NodeID)
fmt.Printf("this node: %s\n", kdb.NodeID())
fmt.Printf("fingerprints: peer=%d this=%d\n", len(remoteFP), len(local))
fmt.Printf(" peer-only (would pull): %d\n", len(peerOnly))
for _, f := range peerOnly[:min(10, len(peerOnly))] {
fmt.Printf(" %s\n", f)
}
fmt.Printf(" local-only (would lose): %d\n", len(localOnly))
for _, f := range localOnly[:min(10, len(localOnly))] {
fmt.Printf(" %s\n", f)
}
// Tombstone divergence on auto-threaded threads.
localThreads, err := kdb.ThreadStatusByCluster()
if err != nil {
return err
}
remoteThreads := remote.ThreadStatus
var resolvedPeer, revokedLocal []string
for k, st := range remoteThreads {
if st == "resolved" {
if ls, ok := localThreads[k]; !ok || ls != "resolved" {
resolvedPeer = append(resolvedPeer, k)
}
}
}
for k, st := range localThreads {
if st == "resolved" {
if rs, ok := remoteThreads[k]; !ok || rs != "resolved" {
revokedLocal = append(revokedLocal, k)
}
}
}
if len(resolvedPeer) > 0 {
fmt.Printf("threads resolved on peer but not here (tombstone to adopt): %d\n", len(resolvedPeer))
}
if len(revokedLocal) > 0 {
fmt.Printf("threads resolved here but not on peer (would resurrect on sync): %d\n", len(revokedLocal))
}
if len(peerOnly) == 0 && len(localOnly) == 0 && len(resolvedPeer) == 0 && len(revokedLocal) == 0 {
fmt.Println("in sync.")
}
return nil
}
func min(a, b int) int {
if a < b {
return a
}
return b
}