Skip to content

Commit f0e4468

Browse files
authored
Merge pull request #341 from aojea/node-debug-typed-payloads
node: replace map[string]any /debug payloads with typed structs
2 parents 6666ce3 + 38896e3 commit f0e4468

3 files changed

Lines changed: 106 additions & 52 deletions

File tree

agents/skills/sam-mesh/SKILL.md

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ Pick the path that matches the need:
1515
[Bootstrap A Node](#bootstrap-a-node).
1616
- The task needs a plain HTTP call to the node, such as inference or a
1717
`local_proxy_url`: [Talk To The Node Over HTTP](#talk-to-the-node-over-http).
18-
- The task needs node diagnostics, such as logs or connectivity:
18+
- The node is up but the mesh seems broken:
1919
[Diagnose The Node](#diagnose-the-node).
2020
- The task needs a remote tool or capability:
2121
[Inspect The Mesh](#inspect-the-mesh).
@@ -104,16 +104,30 @@ it passes through to that service untouched.
104104

105105
## Diagnose The Node
106106

107-
Node diagnostics (logs, connectivity, network and token info, connecting to a
108-
peer by address) are not MCP tools. Discover them instead of memorizing them:
107+
Operator diagnostics are not MCP tools, so they never appear in the tool list.
108+
A running node serves them under `/debug`, and the `sam-node` CLI wraps each
109+
endpoint over the node's Unix socket — no token involved:
109110

110111
```bash
111-
sam-node debug --help
112+
sam-node debug mesh-info # connected peers, DHT size, router peer ID
113+
sam-node debug connectivity [peer-id] # ping the SAM router, or a specific peer
114+
sam-node debug network-info # listen and observed addresses
115+
sam-node debug token-info # local auth token expiration and status
116+
sam-node debug logs # recent log lines
117+
sam-node debug connect-peer <multiaddr> # manually dial a peer
112118
```
113119

114-
Then run the subcommand that matches the need. They talk to the node over its
115-
Unix socket, so no token is involved, and each prints the node's JSON response,
116-
so the output composes with `jq`.
120+
Each command prints the endpoint's raw JSON, so it composes with `jq`. The same
121+
data is one `curl` away when the CLI is not at hand:
122+
123+
```bash
124+
curl --unix-socket ~/.config/sam-mesh/sam.sock http://localhost/debug/mesh-info
125+
```
126+
127+
These endpoints answer even while the mesh is unreachable — that is the state
128+
they exist to diagnose. When the node runs but mesh tools fail, check
129+
`debug connectivity` for `router_error_msg` and `debug token-info` for an
130+
expired token before restarting or re-enrolling anything.
117131

118132
## Inspect The Mesh
119133

internal/node/debug_handlers.go

Lines changed: 75 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ func newDebugHandler(n *SamNode) http.Handler {
5454
writeDebugJSON(w, n.tokenInfo())
5555
})
5656
mux.HandleFunc("GET /debug/logs", func(w http.ResponseWriter, r *http.Request) {
57-
writeDebugJSON(w, map[string]any{"logs": GetRecentLogs()})
57+
writeDebugJSON(w, logsResponse{Logs: GetRecentLogs()})
5858
})
5959
mux.HandleFunc("POST /debug/connect-peer", func(w http.ResponseWriter, r *http.Request) {
6060
var req struct {
@@ -109,9 +109,47 @@ func writeDebugJSON(w http.ResponseWriter, v any) {
109109
}
110110
}
111111

112+
// The types below are the /debug payloads. They are unexported on purpose:
113+
// these endpoints are unversioned operator diagnostics, not part of the
114+
// api/sam.proto mesh contract.
115+
116+
type meshInfoResponse struct {
117+
PeerID string `json:"peer_id"`
118+
ConnectedPeers []string `json:"connected_peers"`
119+
DHTSize int `json:"dht_size"`
120+
RouterPeerID string `json:"router_peer_id"`
121+
LocalAPISocket string `json:"local_api_socket,omitempty"`
122+
}
123+
124+
type connectivityResponse struct {
125+
ConnectedPeers int `json:"connected_peers"`
126+
TotalKnownPeers int `json:"total_known_peers"`
127+
PingLatencyMS *int64 `json:"ping_latency_ms,omitempty"`
128+
PingError *bool `json:"ping_error,omitempty"`
129+
PingErrorMsg string `json:"ping_error_msg,omitempty"`
130+
RouterLatencyMS *int64 `json:"router_latency_ms,omitempty"`
131+
RouterError *bool `json:"router_error,omitempty"`
132+
RouterErrorMsg string `json:"router_error_msg,omitempty"`
133+
}
134+
135+
type tokenInfoResponse struct {
136+
HasToken bool `json:"has_token"`
137+
ExpiresInSeconds *float64 `json:"expires_in_seconds,omitempty"`
138+
IsExpired *bool `json:"is_expired,omitempty"`
139+
}
140+
141+
type networkInfoResponse struct {
142+
ListenAddresses []string `json:"listen_addresses"`
143+
ObservedAddresses []string `json:"observed_addresses"`
144+
}
145+
146+
type logsResponse struct {
147+
Logs []string `json:"logs"`
148+
}
149+
112150
// meshInfo backs both the get_mesh_info MCP tool and GET /debug/mesh-info.
113151
// The MCP path skips the /debug boundary guard, so it re-checks here.
114-
func (n *SamNode) meshInfo() (map[string]any, error) {
152+
func (n *SamNode) meshInfo() (*meshInfoResponse, error) {
115153
if err := n.debugReady(); err != nil {
116154
return nil, err
117155
}
@@ -122,29 +160,25 @@ func (n *SamNode) meshInfo() (map[string]any, error) {
122160
for _, p := range peers {
123161
connectedPeers = append(connectedPeers, p.String())
124162
}
125-
dhtSize := n.DHT.RoutingTable().Size()
126163

127-
resData := map[string]any{
128-
"peer_id": n.Host.ID().String(),
129-
"connected_peers": connectedPeers,
130-
"dht_size": dhtSize,
131-
"router_peer_id": n.RouterPeerID.String(),
132-
}
133-
if n.BoundSocketPath != "" {
134-
resData["local_api_socket"] = n.BoundSocketPath
135-
}
136-
return resData, nil
164+
return &meshInfoResponse{
165+
PeerID: n.Host.ID().String(),
166+
ConnectedPeers: connectedPeers,
167+
DHTSize: n.DHT.RoutingTable().Size(),
168+
RouterPeerID: n.RouterPeerID.String(),
169+
LocalAPISocket: n.BoundSocketPath,
170+
}, nil
137171
}
138172

139173
// connectivityStats backs GET /debug/connectivity: with a peer ID it pings that
140174
// peer, otherwise it pings the SAM router.
141-
func (n *SamNode) connectivityStats(ctx context.Context, peerIDStr string) map[string]any {
175+
func (n *SamNode) connectivityStats(ctx context.Context, peerIDStr string) connectivityResponse {
142176
ctx, cancel := context.WithTimeout(ctx, connectivityPingTimeout)
143177
defer cancel()
144178

145-
stats := map[string]any{
146-
"connected_peers": len(n.Host.Network().Peers()),
147-
"total_known_peers": len(n.Host.Peerstore().Peers()),
179+
stats := connectivityResponse{
180+
ConnectedPeers: len(n.Host.Network().Peers()),
181+
TotalKnownPeers: len(n.Host.Peerstore().Peers()),
148182
}
149183

150184
if peerIDStr != "" {
@@ -153,48 +187,52 @@ func (n *SamNode) connectivityStats(ctx context.Context, peerIDStr string) map[s
153187
n.preparePeerAddrs(ctx, pid)
154188
start := time.Now()
155189
err := n.Host.Connect(ctx, peer.AddrInfo{ID: pid})
156-
stats["ping_latency_ms"] = time.Since(start).Milliseconds()
157-
stats["ping_error"] = err != nil
190+
latency := time.Since(start).Milliseconds()
191+
failed := err != nil
192+
stats.PingLatencyMS = &latency
193+
stats.PingError = &failed
158194
if err != nil {
159-
stats["ping_error_msg"] = err.Error()
195+
stats.PingErrorMsg = err.Error()
160196
}
161197
} else {
162-
stats["ping_error"] = true
163-
stats["ping_error_msg"] = "invalid peer id"
198+
failed := true
199+
stats.PingError = &failed
200+
stats.PingErrorMsg = "invalid peer id"
164201
}
165202
} else if n.RouterPeerID != "" {
166203
start := time.Now()
167204
err := n.Host.Connect(ctx, peer.AddrInfo{ID: n.RouterPeerID})
168-
stats["router_latency_ms"] = time.Since(start).Milliseconds()
169-
stats["router_error"] = err != nil
205+
latency := time.Since(start).Milliseconds()
206+
failed := err != nil
207+
stats.RouterLatencyMS = &latency
208+
stats.RouterError = &failed
170209
if err != nil {
171-
stats["router_error_msg"] = err.Error()
210+
stats.RouterErrorMsg = err.Error()
172211
}
173212
}
174213

175214
return stats
176215
}
177216

178217
// tokenInfo backs GET /debug/token-info.
179-
func (n *SamNode) tokenInfo() map[string]any {
180-
info := map[string]any{
181-
"has_token": false,
182-
}
183-
218+
func (n *SamNode) tokenInfo() tokenInfoResponse {
219+
var info tokenInfoResponse
184220
token, err := n.Store.LoadIdentity()
185221
if err == nil && len(token) > 0 {
186-
info["has_token"] = true
222+
info.HasToken = true
187223
exp, err := n.Store.LoadIdentityExpiration()
188224
if err == nil {
189-
info["expires_in_seconds"] = time.Until(time.Unix(exp, 0)).Seconds()
190-
info["is_expired"] = time.Now().Unix() > exp
225+
expiresIn := time.Until(time.Unix(exp, 0)).Seconds()
226+
expired := time.Now().Unix() > exp
227+
info.ExpiresInSeconds = &expiresIn
228+
info.IsExpired = &expired
191229
}
192230
}
193231
return info
194232
}
195233

196234
// networkInfo backs GET /debug/network-info.
197-
func (n *SamNode) networkInfo() map[string]any {
235+
func (n *SamNode) networkInfo() networkInfoResponse {
198236
listenAddrs := []string{}
199237
for _, a := range n.Host.Network().ListenAddresses() {
200238
listenAddrs = append(listenAddrs, a.String())
@@ -205,9 +243,9 @@ func (n *SamNode) networkInfo() map[string]any {
205243
observedAddrs = append(observedAddrs, a.String())
206244
}
207245

208-
return map[string]any{
209-
"listen_addresses": listenAddrs,
210-
"observed_addresses": observedAddrs,
246+
return networkInfoResponse{
247+
ListenAddresses: listenAddrs,
248+
ObservedAddresses: observedAddrs,
211249
}
212250
}
213251

internal/node/mcp_handlers_additional_test.go

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -173,9 +173,13 @@ func TestConnectivityStats(t *testing.T) {
173173
defer cleanup1()
174174

175175
stats := node1.connectivityStats(context.Background(), "")
176+
if stats.ConnectedPeers < 0 || stats.TotalKnownPeers < 0 {
177+
t.Fatalf("nonsensical peer counts: %+v", stats)
178+
}
176179

177-
if _, ok := stats["connected_peers"]; !ok {
178-
t.Fatalf("missing connected_peers in stats")
180+
invalid := node1.connectivityStats(context.Background(), "not-a-peer-id")
181+
if invalid.PingError == nil || !*invalid.PingError || invalid.PingErrorMsg != "invalid peer id" {
182+
t.Fatalf("expected invalid peer id ping error, got %+v", invalid)
179183
}
180184
}
181185

@@ -185,9 +189,8 @@ func TestTokenInfo(t *testing.T) {
185189
defer cleanup1()
186190

187191
info := node1.tokenInfo()
188-
189-
if _, ok := info["has_token"].(bool); !ok {
190-
t.Fatalf("expected has_token boolean, got %v", info["has_token"])
192+
if info.HasToken {
193+
t.Fatalf("bare node should have no token, got %+v", info)
191194
}
192195
}
193196

@@ -197,9 +200,8 @@ func TestNetworkInfo(t *testing.T) {
197200
defer cleanup1()
198201

199202
info := node1.networkInfo()
200-
201-
if _, ok := info["listen_addresses"]; !ok {
202-
t.Fatalf("missing listen_addresses")
203+
if len(info.ListenAddresses) == 0 {
204+
t.Fatalf("expected listen addresses, got %+v", info)
203205
}
204206
}
205207

0 commit comments

Comments
 (0)