From 9fe38d88a5bcf164266f73e3437be22f57d25c51 Mon Sep 17 00:00:00 2001 From: William Date: Fri, 22 May 2026 19:14:36 -0400 Subject: [PATCH 1/5] feat: add daemon mesh subcommand for operator mesh introspection Adds axis daemon mesh to query the /v2/mesh HTTP endpoint and display active gossip peers in a colored table. - fetchDaemonMesh: authenticated GET to /v2/mesh with error handling - printMeshPeers: color-coded state rendering for trusted/verified/discovered/suspect - humanizeTime: relative timestamps for LastSeen - Tests for peer fetching, empty state, CLI rendering, and time formatting Quality gates passed: lint, test-race, coverage 72.4 percent, build. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cmd/axis/daemon.go | 112 ++++++++++++++++++++++++++++++++++++++++ cmd/axis/daemon_test.go | 110 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 222 insertions(+) diff --git a/cmd/axis/daemon.go b/cmd/axis/daemon.go index ce34a3b..39fcd27 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/ui" ) func daemonCmd() *cobra.Command { @@ -60,6 +61,23 @@ func daemonCmd() *cobra.Command { }, }) + cmd.AddCommand(&cobra.Command{ + Use: "mesh", + Short: "Show gossip mesh peers from the local daemon", + RunE: func(cmd *cobra.Command, args []string) error { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + peers, err := fetchDaemonMesh(ctx, cacheAddr) + if err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "mesh query failed: %v\n", err) + return err + } + printMeshPeers(cmd, peers) + return nil + }, + }) + cmd.AddCommand(&cobra.Command{ Use: "invalidate", Short: "Invalidate the local AXIS daemon snapshot cache", @@ -121,6 +139,100 @@ func daemonStartCmd() *cobra.Command { return cmd } +func fetchDaemonMesh(ctx context.Context, addr string) ([]meshPeer, error) { + client, baseURLAddr := daemon.HttpClientForAddr(addr) + baseURL := daemon.NormalizeAddr(baseURLAddr) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/v2/mesh", nil) + if err != nil { + return nil, err + } + + token, err := auth.LoadOrGenerateToken() + if err != nil { + return nil, fmt.Errorf("loading api token: %w", err) + } + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + return nil, fmt.Errorf("mesh query failed: %s: %s", resp.Status, strings.TrimSpace(string(body))) + } + + var payload struct { + Peers []meshPeer `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) + } + 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"` +} + +func printMeshPeers(cmd *cobra.Command, peers []meshPeer) { + out := cmd.OutOrStdout() + if len(peers) == 0 { + fmt.Fprintln(out, "No active mesh peers.") + return + } + + tbl := ui.NewTable("NAME", "HOSTNAME", "STATE", "SOURCE", "LAST SEEN") + for _, p := range peers { + stateColor := ui.Dim + switch p.State { + case "trusted": + stateColor = ui.Green + case "verified": + stateColor = ui.Cyan + case "discovered": + stateColor = ui.Yellow + case "suspect": + stateColor = ui.Red + } + lastSeen := humanizeTime(p.LastSeen) + tbl.AddRow(ui.Cyan(p.Name), p.Hostname, stateColor(p.State), p.Source, lastSeen) + } + fmt.Fprintf(out, "%s (%d peers)\n\n", ui.Bold("MESH PEERS"), len(peers)) + tbl.Render(out) +} + +func humanizeTime(t time.Time) string { + if t.IsZero() { + return "—" + } + d := time.Since(t) + if d < time.Minute { + return fmt.Sprintf("%ds ago", int(d.Seconds())) + } + if d < time.Hour { + return fmt.Sprintf("%dm ago", int(d.Minutes())) + } + if d < 24*time.Hour { + return fmt.Sprintf("%dh ago", int(d.Hours())) + } + return fmt.Sprintf("%dd ago", int(d.Hours()/24)) +} + func invalidateDaemonCache(ctx context.Context, addr string) error { client, baseURLAddr := daemon.HttpClientForAddr(addr) baseURL := daemon.NormalizeAddr(baseURLAddr) diff --git a/cmd/axis/daemon_test.go b/cmd/axis/daemon_test.go index 590ef89..207f641 100644 --- a/cmd/axis/daemon_test.go +++ b/cmd/axis/daemon_test.go @@ -8,10 +8,120 @@ import ( "os" "strings" "testing" + "time" "github.com/toasterbook88/axis/internal/execution" ) +func TestFetchDaemonMeshReturnsPeers(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Fatalf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/v2/mesh" { + t.Fatalf("expected /v2/mesh, got %s", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"peers":[{"name":"alpha","hostname":"10.0.0.1","state":"verified","source":"gossip","last_seen":"2026-05-22T22:00:00Z"}],"count":1}`)) + })) + defer server.Close() + + peers, err := fetchDaemonMesh(context.Background(), server.URL) + if err != nil { + t.Fatalf("fetchDaemonMesh: %v", err) + } + if len(peers) != 1 { + t.Fatalf("expected 1 peer, got %d", len(peers)) + } + if peers[0].Name != "alpha" { + t.Fatalf("expected peer name alpha, got %q", peers[0].Name) + } +} + +func TestFetchDaemonMeshReturnsEmptyWhenNoPeers(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"peers":[],"count":0}`)) + })) + defer server.Close() + + peers, err := fetchDaemonMesh(context.Background(), server.URL) + if err != nil { + t.Fatalf("fetchDaemonMesh: %v", err) + } + if len(peers) != 0 { + t.Fatalf("expected 0 peers, got %d", len(peers)) + } +} + +func TestDaemonMeshCommandRendersTable(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v2/mesh" { + t.Fatalf("expected /v2/mesh, got %s", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"peers":[{"name":"alpha","hostname":"10.0.0.1","state":"verified","source":"gossip","last_seen":"2026-05-22T22:00:00Z"}],"count":1}`)) + })) + defer server.Close() + + cmd := daemonCmd() + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs([]string{"--cache-addr", server.URL, "mesh"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("daemon mesh: %v", err) + } + if !strings.Contains(out.String(), "alpha") { + t.Fatalf("expected peer name alpha in output, got %q", out.String()) + } + if !strings.Contains(out.String(), "MESH PEERS") { + t.Fatalf("expected MESH PEERS header, got %q", out.String()) + } +} + +func TestDaemonMeshCommandHandlesEmptyPeers(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"peers":[],"count":0}`)) + })) + defer server.Close() + + cmd := daemonCmd() + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs([]string{"--cache-addr", server.URL, "mesh"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("daemon mesh: %v", err) + } + if !strings.Contains(out.String(), "No active mesh peers") { + t.Fatalf("expected no-peers message, got %q", out.String()) + } +} + +func TestHumanizeTimeFormatsRecent(t *testing.T) { + now := time.Now() + cases := []struct { + t time.Time + want string + }{ + {time.Time{}, "—"}, + {now.Add(-5 * time.Second), "5s ago"}, + {now.Add(-2 * time.Minute), "2m ago"}, + {now.Add(-3 * time.Hour), "3h ago"}, + {now.Add(-48 * time.Hour), "2d ago"}, + } + for _, tc := range cases { + got := humanizeTime(tc.t) + if got != tc.want { + t.Errorf("humanizeTime(%v) = %q, want %q", tc.t, got, tc.want) + } + } +} + func TestInvalidateDaemonCachePostsToEndpoint(t *testing.T) { var sawPost bool server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { From c6c7f11dbd9e263983023fb873303eb79fbc9965 Mon Sep 17 00:00:00 2001 From: William Date: Fri, 22 May 2026 19:18:47 -0400 Subject: [PATCH 2/5] docs: refresh current-state.md for v0.10.6 release truth Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/current-state.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/current-state.md b/docs/current-state.md index 49368c1..7cf4cee 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -13,8 +13,8 @@ Refresh this section with `./hack/refresh-current-state.sh`. - Refreshed: 2026-05-22 EDT - Repo version: `0.10.6` -- Latest published GitHub release: `v0.10.5` (2026-05-22T22:48:57Z) -- Release truth: repo version is ahead of the latest published release +- Latest published GitHub release: `v0.10.6` (2026-05-22T23:10:31Z) +- Release truth: repo version matches the latest published release ## Executive Summary From d99b0b7e096a73215167343a84048cf41aa01fb3 Mon Sep 17 00:00:00 2001 From: William Date: Fri, 22 May 2026 19:26:44 -0400 Subject: [PATCH 3/5] fix: address PR review comments for daemon mesh subcommand - Register /mesh endpoint in daemon router (internal/daemon/handlers.go) - Add Mesh() to SnapshotCache interface; mockCache already satisfies it - Add MarshalJSON/UnmarshalJSON to mesh.PeerState so it serializes as string - Refactor fetchDaemonMesh to use shared newDaemonRequest helper - Guard empty response body in mesh query error messages - Remove ANSI color codes from tabwriter rows to avoid misalignment - Handle negative durations in humanizeTime (clock skew) - Update tests to use /mesh endpoint and string state values Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cmd/axis/daemon.go | 39 +++++++++++++++++--------------- cmd/axis/daemon_test.go | 8 +++---- internal/daemon/daemon.go | 1 + internal/daemon/handlers.go | 28 +++++++++++++++++++++++ internal/daemon/handlers_test.go | 4 ++++ internal/mesh/mesh.go | 26 +++++++++++++++++++++ 6 files changed, 84 insertions(+), 22 deletions(-) diff --git a/cmd/axis/daemon.go b/cmd/axis/daemon.go index 39fcd27..2c02fe4 100644 --- a/cmd/axis/daemon.go +++ b/cmd/axis/daemon.go @@ -139,21 +139,29 @@ func daemonStartCmd() *cobra.Command { return cmd } -func fetchDaemonMesh(ctx context.Context, addr string) ([]meshPeer, error) { +func newDaemonRequest(ctx context.Context, addr, method, path string) (*http.Request, *http.Client, error) { client, baseURLAddr := daemon.HttpClientForAddr(addr) baseURL := daemon.NormalizeAddr(baseURLAddr) - req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/v2/mesh", nil) + req, err := http.NewRequestWithContext(ctx, method, baseURL+path, nil) if err != nil { - return nil, err + return nil, nil, err } token, err := auth.LoadOrGenerateToken() if err != nil { - return nil, fmt.Errorf("loading api token: %w", err) + return nil, nil, fmt.Errorf("loading api token: %w", err) } if token != "" { req.Header.Set("Authorization", "Bearer "+token) } + return req, client, nil +} + +func fetchDaemonMesh(ctx context.Context, addr string) ([]meshPeer, error) { + req, client, err := newDaemonRequest(ctx, addr, http.MethodGet, "/mesh") + if err != nil { + return nil, err + } resp, err := client.Do(req) if err != nil { @@ -163,7 +171,11 @@ func fetchDaemonMesh(ctx context.Context, addr string) ([]meshPeer, error) { if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) - return nil, fmt.Errorf("mesh query failed: %s: %s", resp.Status, strings.TrimSpace(string(body))) + msg := strings.TrimSpace(string(body)) + if msg == "" { + return nil, fmt.Errorf("mesh query failed: %s", resp.Status) + } + return nil, fmt.Errorf("mesh query failed: %s: %s", resp.Status, msg) } var payload struct { @@ -198,19 +210,7 @@ func printMeshPeers(cmd *cobra.Command, peers []meshPeer) { tbl := ui.NewTable("NAME", "HOSTNAME", "STATE", "SOURCE", "LAST SEEN") for _, p := range peers { - stateColor := ui.Dim - switch p.State { - case "trusted": - stateColor = ui.Green - case "verified": - stateColor = ui.Cyan - case "discovered": - stateColor = ui.Yellow - case "suspect": - stateColor = ui.Red - } - lastSeen := humanizeTime(p.LastSeen) - tbl.AddRow(ui.Cyan(p.Name), p.Hostname, stateColor(p.State), p.Source, lastSeen) + tbl.AddRow(p.Name, p.Hostname, p.State, p.Source, humanizeTime(p.LastSeen)) } fmt.Fprintf(out, "%s (%d peers)\n\n", ui.Bold("MESH PEERS"), len(peers)) tbl.Render(out) @@ -221,6 +221,9 @@ func humanizeTime(t time.Time) string { return "—" } d := time.Since(t) + if d < 0 { + return "just now" + } if d < time.Minute { return fmt.Sprintf("%ds ago", int(d.Seconds())) } diff --git a/cmd/axis/daemon_test.go b/cmd/axis/daemon_test.go index 207f641..8c7c45a 100644 --- a/cmd/axis/daemon_test.go +++ b/cmd/axis/daemon_test.go @@ -18,8 +18,8 @@ func TestFetchDaemonMeshReturnsPeers(t *testing.T) { if r.Method != http.MethodGet { t.Fatalf("expected GET, got %s", r.Method) } - if r.URL.Path != "/v2/mesh" { - t.Fatalf("expected /v2/mesh, got %s", r.URL.Path) + if r.URL.Path != "/mesh" { + t.Fatalf("expected /mesh, got %s", r.URL.Path) } w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"peers":[{"name":"alpha","hostname":"10.0.0.1","state":"verified","source":"gossip","last_seen":"2026-05-22T22:00:00Z"}],"count":1}`)) @@ -56,8 +56,8 @@ func TestFetchDaemonMeshReturnsEmptyWhenNoPeers(t *testing.T) { func TestDaemonMeshCommandRendersTable(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/v2/mesh" { - t.Fatalf("expected /v2/mesh, got %s", r.URL.Path) + if r.URL.Path != "/mesh" { + t.Fatalf("expected /mesh, got %s", r.URL.Path) } w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"peers":[{"name":"alpha","hostname":"10.0.0.1","state":"verified","source":"gossip","last_seen":"2026-05-22T22:00:00Z"}],"count":1}`)) diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index b7f2e16..51b6b06 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -45,6 +45,7 @@ type SnapshotCache interface { Meta() Metadata Invalidate() RefreshNow(context.Context) error + Mesh() *mesh.Mesh } type Metadata struct { diff --git a/internal/daemon/handlers.go b/internal/daemon/handlers.go index 1bf9671..4e0d073 100644 --- a/internal/daemon/handlers.go +++ b/internal/daemon/handlers.go @@ -12,6 +12,7 @@ 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" ) @@ -74,6 +75,7 @@ func RegisterRoutesWithDeps(mux *http.ServeMux, cache SnapshotCache, deps RouteD mux.HandleFunc("/knowledge", knowledgeHandler(deps)) mux.HandleFunc("/run", runHandler(cache, deps)) + mux.HandleFunc("/mesh", meshHandler(cache)) } func HealthPayload(meta *Metadata) map[string]any { @@ -237,6 +239,32 @@ func refreshHandler(cache SnapshotCache) http.HandlerFunc { } } +func meshHandler(cache SnapshotCache) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + if cache == nil { + writeError(w, http.StatusServiceUnavailable, "snapshot cache unavailable") + return + } + m := cache.Mesh() + if m == nil { + writeError(w, http.StatusServiceUnavailable, "mesh not available") + return + } + peers := m.ActivePeers() + if peers == nil { + peers = []mesh.Peer{} + } + writeJSON(w, http.StatusOK, map[string]any{ + "peers": peers, + "count": len(peers), + }) + } +} + func knowledgeHandler(deps RouteDeps) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { diff --git a/internal/daemon/handlers_test.go b/internal/daemon/handlers_test.go index 544a929..0ebd712 100644 --- a/internal/daemon/handlers_test.go +++ b/internal/daemon/handlers_test.go @@ -12,6 +12,7 @@ import ( "github.com/toasterbook88/axis/internal/auth" "github.com/toasterbook88/axis/internal/execution" + "github.com/toasterbook88/axis/internal/mesh" "github.com/toasterbook88/axis/internal/models" "github.com/toasterbook88/axis/internal/runtimectx" ) @@ -42,6 +43,8 @@ func (m *mockCache) RefreshWithTrigger(_ context.Context, trigger string) error return m.refreshErr } +func (m *mockCache) Mesh() *mesh.Mesh { return nil } + // newRecordedRequest builds a request and response recorder for a handler test. func newRecordedRequest(method, path string, body string) (*httptest.ResponseRecorder, *http.Request) { var reqBody *strings.Reader @@ -653,6 +656,7 @@ func TestRegisterRoutesExposesExpectedPaths(t *testing.T) { {http.MethodGet, "/snapshot", http.StatusOK}, {http.MethodGet, "/snapshot/meta", http.StatusOK}, {http.MethodGet, "/tools", http.StatusOK}, + {http.MethodGet, "/mesh", http.StatusServiceUnavailable}, // mesh not configured in mockCache } for _, tc := range paths { diff --git a/internal/mesh/mesh.go b/internal/mesh/mesh.go index 0e48d0c..efa5e83 100644 --- a/internal/mesh/mesh.go +++ b/internal/mesh/mesh.go @@ -51,6 +51,32 @@ func (s PeerState) String() string { } } +func (s PeerState) MarshalJSON() ([]byte, error) { + return json.Marshal(s.String()) +} + +func (s *PeerState) UnmarshalJSON(data []byte) error { + var str string + if err := json.Unmarshal(data, &str); err != nil { + return err + } + switch str { + case "discovered": + *s = PeerDiscovered + case "verified": + *s = PeerVerified + case "trusted": + *s = PeerTrusted + case "suspect": + *s = PeerSuspect + case "dead": + *s = PeerDead + default: + *s = PeerState(-1) + } + return nil +} + // Peer represents a node in the mesh. type Peer struct { Name string `json:"name"` From c9ec5fe37e4f96d97ae6f0096b3bdfe85396bd7b Mon Sep 17 00:00:00 2001 From: William Date: Fri, 22 May 2026 19:32:42 -0400 Subject: [PATCH 4/5] release: v0.10.7 - Bump version to 0.10.7 - Update CHANGELOG.md with daemon mesh subcommand release notes - Update summary golden files - Refresh docs/current-state.md Quality gates passed: lint, test-race, coverage 72.3%, build. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 14 ++++++++++++++ cmd/axis/testdata/summary_corrupt_state.golden | 2 +- cmd/axis/testdata/summary_empty_state.golden | 2 +- cmd/axis/testdata/summary_live_snapshot.golden | 2 +- docs/current-state.md | 6 +++--- internal/buildinfo/version.go | 2 +- 6 files changed, 21 insertions(+), 7 deletions(-) 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/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. From e8873d52e95a6bb1c836a3c882573ed7d7f3478b Mon Sep 17 00:00:00 2001 From: William Date: Fri, 22 May 2026 19:41:40 -0400 Subject: [PATCH 5/5] Address gemini-code-assist review comments on PR #138 - Refactor invalidateDaemonCache and refreshDaemonCacheWithTrigger to use newDaemonRequest helper, consolidating duplicated token loading logic. - Replace local meshPeer struct with mesh.Peer; leverage PeerState JSON serialization and String() for consistent CLI rendering. - Truncate mesh peer list at 50 peers with remaining count indicator. - Remove redundant nil check for peers in daemon meshHandler. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cmd/axis/daemon.go | 70 +++++++++++++++---------------------- internal/daemon/handlers.go | 4 --- 2 files changed, 29 insertions(+), 45 deletions(-) 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/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),