diff --git a/CHANGELOG.md b/CHANGELOG.md index 955f443..6bdcbf7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,17 @@ +## v0.10.7 (2026-05-22) + +### 🚀 Features +* New `axis daemon mesh` subcommand for operator mesh introspection. + - Queries the daemon's `/mesh` endpoint and displays active gossip peers in a table. + - Shows peer name, hostname, state, source, and relative last-seen time. + - Handles empty peer lists and "mesh not available" gracefully. + +### 🔧 Internal +* Added `Mesh() *mesh.Mesh` to the `daemon.SnapshotCache` interface and implemented it on `*Daemon`. +* Added `/mesh` handler to the daemon router (`internal/daemon/handlers.go`). +* Added `MarshalJSON`/`UnmarshalJSON` to `mesh.PeerState` so the API serializes states as human-readable strings (`"discovered"`, `"verified"`, `"trusted"`, `"suspect"`, `"dead"`). +* Refactored daemon HTTP request creation into shared `newDaemonRequest` helper with consistent auth handling. + ## v0.10.6 (2026-05-22) ### 🚀 Features diff --git a/cmd/axis/daemon.go b/cmd/axis/daemon.go index 2c02fe4..c45c2ae 100644 --- a/cmd/axis/daemon.go +++ b/cmd/axis/daemon.go @@ -20,6 +20,7 @@ import ( "github.com/toasterbook88/axis/internal/api" "github.com/toasterbook88/axis/internal/auth" "github.com/toasterbook88/axis/internal/daemon" + "github.com/toasterbook88/axis/internal/mesh" "github.com/toasterbook88/axis/internal/ui" ) @@ -139,10 +140,14 @@ func daemonStartCmd() *cobra.Command { return cmd } -func newDaemonRequest(ctx context.Context, addr, method, path string) (*http.Request, *http.Client, error) { +func newDaemonRequest(ctx context.Context, addr, method, path string, query url.Values) (*http.Request, *http.Client, error) { client, baseURLAddr := daemon.HttpClientForAddr(addr) baseURL := daemon.NormalizeAddr(baseURLAddr) - req, err := http.NewRequestWithContext(ctx, method, baseURL+path, nil) + u := baseURL + path + if query != nil { + u += "?" + query.Encode() + } + req, err := http.NewRequestWithContext(ctx, method, u, nil) if err != nil { return nil, nil, err } @@ -157,8 +162,8 @@ func newDaemonRequest(ctx context.Context, addr, method, path string) (*http.Req return req, client, nil } -func fetchDaemonMesh(ctx context.Context, addr string) ([]meshPeer, error) { - req, client, err := newDaemonRequest(ctx, addr, http.MethodGet, "/mesh") +func fetchDaemonMesh(ctx context.Context, addr string) ([]mesh.Peer, error) { + req, client, err := newDaemonRequest(ctx, addr, http.MethodGet, "/mesh", nil) if err != nil { return nil, err } @@ -179,8 +184,8 @@ func fetchDaemonMesh(ctx context.Context, addr string) ([]meshPeer, error) { } var payload struct { - Peers []meshPeer `json:"peers"` - Count int `json:"count"` + Peers []mesh.Peer `json:"peers"` + Count int `json:"count"` } if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { return nil, fmt.Errorf("decoding mesh response: %w", err) @@ -188,20 +193,9 @@ func fetchDaemonMesh(ctx context.Context, addr string) ([]meshPeer, error) { return payload.Peers, nil } -type meshPeer struct { - Name string `json:"name"` - Hostname string `json:"hostname"` - Port int `json:"port"` - StableID string `json:"stable_id"` - State string `json:"state"` - Source string `json:"source"` - FirstSeen time.Time `json:"first_seen"` - LastSeen time.Time `json:"last_seen"` - MissedPings int `json:"missed_pings"` - Generation uint64 `json:"generation"` -} +const maxMeshPeersDisplayed = 50 -func printMeshPeers(cmd *cobra.Command, peers []meshPeer) { +func printMeshPeers(cmd *cobra.Command, peers []mesh.Peer) { out := cmd.OutOrStdout() if len(peers) == 0 { fmt.Fprintln(out, "No active mesh peers.") @@ -209,11 +203,20 @@ func printMeshPeers(cmd *cobra.Command, peers []meshPeer) { } tbl := ui.NewTable("NAME", "HOSTNAME", "STATE", "SOURCE", "LAST SEEN") - for _, p := range peers { - tbl.AddRow(p.Name, p.Hostname, p.State, p.Source, humanizeTime(p.LastSeen)) + displayed := peers + remaining := 0 + if len(peers) > maxMeshPeersDisplayed { + displayed = peers[:maxMeshPeersDisplayed] + remaining = len(peers) - maxMeshPeersDisplayed + } + for _, p := range displayed { + tbl.AddRow(p.Name, p.Hostname, p.State.String(), p.Source, humanizeTime(p.LastSeen)) } fmt.Fprintf(out, "%s (%d peers)\n\n", ui.Bold("MESH PEERS"), len(peers)) tbl.Render(out) + if remaining > 0 { + fmt.Fprintf(out, "\n... and %d more peers not shown\n", remaining) + } } func humanizeTime(t time.Time) string { @@ -237,13 +240,10 @@ func humanizeTime(t time.Time) string { } func invalidateDaemonCache(ctx context.Context, addr string) error { - client, baseURLAddr := daemon.HttpClientForAddr(addr) - baseURL := daemon.NormalizeAddr(baseURLAddr) - req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+"/invalidate", nil) + req, client, err := newDaemonRequest(ctx, addr, http.MethodPost, "/invalidate", nil) if err != nil { return err } - return doDaemonActionWithClient(client, req, "daemon invalidate failed") } @@ -252,35 +252,23 @@ func refreshDaemonCache(ctx context.Context, addr string) error { } func refreshDaemonCacheWithTrigger(ctx context.Context, addr, trigger string) error { - client, baseURLAddr := daemon.HttpClientForAddr(addr) - baseURL := daemon.NormalizeAddr(baseURLAddr) - endpoint := baseURL + "/refresh" + var query url.Values if trigger != "" { normalized, err := daemon.NormalizeRefreshTrigger(trigger) if err != nil { return err } - values := url.Values{} - values.Set("trigger", normalized) - endpoint += "?" + values.Encode() + query = url.Values{} + query.Set("trigger", normalized) } - req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, nil) + req, client, err := newDaemonRequest(ctx, addr, http.MethodPost, "/refresh", query) if err != nil { return err } - return doDaemonActionWithClient(client, req, "daemon refresh failed") } func doDaemonActionWithClient(client *http.Client, req *http.Request, prefix string) error { - token, err := auth.LoadOrGenerateToken() - if err != nil { - return fmt.Errorf("%s: loading api token: %w", prefix, err) - } - if token != "" { - req.Header.Set("Authorization", "Bearer "+token) - } - resp, err := client.Do(req) if err != nil { return err diff --git a/cmd/axis/testdata/summary_corrupt_state.golden b/cmd/axis/testdata/summary_corrupt_state.golden index b38823c..edf62fe 100644 --- a/cmd/axis/testdata/summary_corrupt_state.golden +++ b/cmd/axis/testdata/summary_corrupt_state.golden @@ -3,7 +3,7 @@ ║ AXIS CLUSTER SUMMARY ║ ╚══════════════════════════════════════════════╝ - Version: 0.10.6 + Version: 0.10.7 NODES ───────────────────────────────── diff --git a/cmd/axis/testdata/summary_empty_state.golden b/cmd/axis/testdata/summary_empty_state.golden index 1c9ec0c..c4e498d 100644 --- a/cmd/axis/testdata/summary_empty_state.golden +++ b/cmd/axis/testdata/summary_empty_state.golden @@ -3,7 +3,7 @@ ║ AXIS CLUSTER SUMMARY ║ ╚══════════════════════════════════════════════╝ - Version: 0.10.6 + Version: 0.10.7 NODES ───────────────────────────────── diff --git a/cmd/axis/testdata/summary_live_snapshot.golden b/cmd/axis/testdata/summary_live_snapshot.golden index 1ba714c..375cb1c 100644 --- a/cmd/axis/testdata/summary_live_snapshot.golden +++ b/cmd/axis/testdata/summary_live_snapshot.golden @@ -7,7 +7,7 @@ STDOUT: ║ AXIS CLUSTER SUMMARY ║ ╚══════════════════════════════════════════════╝ - Version: 0.10.6 + Version: 0.10.7 NODES ───────────────────────────────── diff --git a/docs/current-state.md b/docs/current-state.md index 7cf4cee..5f236e5 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -12,9 +12,9 @@ Refresh this section with `./hack/refresh-current-state.sh`. - Refreshed: 2026-05-22 EDT -- Repo version: `0.10.6` +- Repo version: `0.10.7` - Latest published GitHub release: `v0.10.6` (2026-05-22T23:10:31Z) -- Release truth: repo version matches the latest published release +- Release truth: repo version is ahead of the latest published release ## Executive Summary @@ -149,7 +149,7 @@ Refresh this section with `./hack/refresh-current-state.sh`. - `coverage gate passed: internal/api 80.2% >= 50.0%` - `coverage gate passed: internal/mcp 88.9% >= 35.0%` - `coverage gate passed: internal/ui 94.0% >= 80.0%` - - `coverage gate passed: total 72.4% >= 45.0%` + - `coverage gate passed: total 72.3% >= 45.0%` ## Degraded-State Matrix diff --git a/internal/buildinfo/version.go b/internal/buildinfo/version.go index c1d6d40..dccf651 100644 --- a/internal/buildinfo/version.go +++ b/internal/buildinfo/version.go @@ -1,7 +1,7 @@ package buildinfo // Version is the single source of truth for the AXIS release string. -const Version = "0.10.6" +const Version = "0.10.7" // UpdateManagedBy specifies if this binary is managed by a package manager (e.g. "nix", "homebrew"). // When set, the internal `axis update` command will refuse to overwrite the binary. diff --git a/internal/daemon/handlers.go b/internal/daemon/handlers.go index 4e0d073..35b159a 100644 --- a/internal/daemon/handlers.go +++ b/internal/daemon/handlers.go @@ -12,7 +12,6 @@ import ( "github.com/toasterbook88/axis/internal/auth" "github.com/toasterbook88/axis/internal/execution" "github.com/toasterbook88/axis/internal/knowledge" - "github.com/toasterbook88/axis/internal/mesh" "github.com/toasterbook88/axis/internal/runtimectx" "github.com/toasterbook88/axis/internal/skills" ) @@ -255,9 +254,6 @@ func meshHandler(cache SnapshotCache) http.HandlerFunc { return } peers := m.ActivePeers() - if peers == nil { - peers = []mesh.Peer{} - } writeJSON(w, http.StatusOK, map[string]any{ "peers": peers, "count": len(peers),