Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
70 changes: 29 additions & 41 deletions cmd/axis/daemon.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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
}
Expand All @@ -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
}
Expand All @@ -179,41 +184,39 @@ 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)
}
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.")
return
}

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 {
Expand All @@ -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")
}

Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion cmd/axis/testdata/summary_corrupt_state.golden
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
║ AXIS CLUSTER SUMMARY ║
╚══════════════════════════════════════════════╝

Version: 0.10.6
Version: 0.10.7

NODES
─────────────────────────────────
Expand Down
2 changes: 1 addition & 1 deletion cmd/axis/testdata/summary_empty_state.golden
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
║ AXIS CLUSTER SUMMARY ║
╚══════════════════════════════════════════════╝

Version: 0.10.6
Version: 0.10.7

NODES
─────────────────────────────────
Expand Down
2 changes: 1 addition & 1 deletion cmd/axis/testdata/summary_live_snapshot.golden
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ STDOUT:
║ AXIS CLUSTER SUMMARY ║
╚══════════════════════════════════════════════╝

Version: 0.10.6
Version: 0.10.7

NODES
─────────────────────────────────
Expand Down
6 changes: 3 additions & 3 deletions docs/current-state.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@ Refresh this section with `./hack/refresh-current-state.sh`.

<!-- BEGIN GENERATED CURRENT STATE FACTS -->
- 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
<!-- END GENERATED CURRENT STATE FACTS -->

## Executive Summary
Expand Down Expand Up @@ -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%`
<!-- END GENERATED CURRENT STATE VERIFICATION -->

## Degraded-State Matrix
Expand Down
2 changes: 1 addition & 1 deletion internal/buildinfo/version.go
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
4 changes: 0 additions & 4 deletions internal/daemon/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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),
Expand Down