Skip to content

Commit a1931d6

Browse files
gcmsgclaude
andcommitted
feat: SDK version tracking with upgrade prompts
Persist heartbeat metadata (sdk_version) via Store.UpdateMetadata, add GitHub-based version check service polling latest CLI release, expose sdk_version in provider/admin API responses, and display version badges with upgrade prompt modals in the dashboard (8-locale i18n). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 40f627d commit a1931d6

File tree

24 files changed

+521
-22
lines changed

24 files changed

+521
-22
lines changed

cmd/peerclawd/main.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import (
3333
"github.com/peerclaw/peerclaw-server/internal/review"
3434
"github.com/peerclaw/peerclaw-server/internal/useracl"
3535
"github.com/peerclaw/peerclaw-server/internal/userauth"
36+
"github.com/peerclaw/peerclaw-server/internal/versioncheck"
3637
"github.com/peerclaw/peerclaw-server/internal/verification"
3738
goredis "github.com/redis/go-redis/v9"
3839
)
@@ -395,6 +396,18 @@ func main() {
395396
sigHub.SetBroker(signaling.NewLocalBroker(sigHub))
396397
}
397398
}
399+
// Initialize version check service.
400+
if cfg.VersionCheck.Enabled {
401+
vcInterval, err := time.ParseDuration(cfg.VersionCheck.Interval)
402+
if err != nil {
403+
vcInterval = time.Hour
404+
}
405+
vcService := versioncheck.New(cfg.VersionCheck.Repo, vcInterval, logger)
406+
httpServer.SetVersionCheck(vcService)
407+
go vcService.Start(ctx)
408+
logger.Info("version check service started", "repo", cfg.VersionCheck.Repo, "interval", vcInterval)
409+
}
410+
398411
// Register routes after all services are configured so that
399412
// optional-service routes (claim tokens, contacts, etc.) are included.
400413
httpServer.RegisterRoutes()

internal/config/config.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ type Config struct {
2424
UserAuth UserAuthConfig `yaml:"user_auth"`
2525
SMTP SMTPConfig `yaml:"smtp"`
2626
Retention RetentionConfig `yaml:"retention"`
27+
VersionCheck VersionCheckConfig `yaml:"version_check"`
2728
}
2829

2930
// SMTPConfig holds SMTP email settings.
@@ -45,6 +46,13 @@ type RetentionConfig struct {
4546
CleanupInterval string `yaml:"cleanup_interval"`
4647
}
4748

49+
// VersionCheckConfig holds SDK version check settings.
50+
type VersionCheckConfig struct {
51+
Enabled bool `yaml:"enabled"` // default true
52+
Repo string `yaml:"repo"` // default "peerclaw/peerclaw-cli"
53+
Interval string `yaml:"interval"` // default "1h"
54+
}
55+
4856
// AuthConfig holds authentication settings.
4957
type AuthConfig struct {
5058
Required bool `yaml:"required"` // When true, reject unauthenticated requests. Default true.
@@ -206,6 +214,11 @@ func DefaultConfig() *Config {
206214
RefreshTTL: "168h",
207215
BcryptCost: 12,
208216
},
217+
VersionCheck: VersionCheckConfig{
218+
Enabled: true,
219+
Repo: "peerclaw/peerclaw-cli",
220+
Interval: "1h",
221+
},
209222
Retention: RetentionConfig{
210223
Enabled: true,
211224
ReputationEventsDays: 90,

internal/registry/postgres.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,25 @@ func (s *PostgresStore) UpdateHeartbeat(ctx context.Context, id string, status a
255255
return nil
256256
}
257257

258+
func (s *PostgresStore) UpdateMetadata(ctx context.Context, id string, metadata map[string]string) error {
259+
if len(metadata) == 0 {
260+
return nil
261+
}
262+
patch, _ := json.Marshal(metadata)
263+
res, err := s.db.ExecContext(ctx,
264+
"UPDATE agents SET metadata = COALESCE(metadata::jsonb, '{}'::jsonb) || $1::jsonb WHERE id = $2",
265+
string(patch), id,
266+
)
267+
if err != nil {
268+
return err
269+
}
270+
n, _ := res.RowsAffected()
271+
if n == 0 {
272+
return fmt.Errorf("agent %s not found", id)
273+
}
274+
return nil
275+
}
276+
258277
func (s *PostgresStore) FindByCapabilities(ctx context.Context, capabilities []string, proto string, maxResults int) ([]*agentcard.Card, error) {
259278
var conditions []string
260279
var args []any

internal/registry/service.go

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -108,14 +108,23 @@ func (s *Service) Deregister(ctx context.Context, agentID string) error {
108108
return nil
109109
}
110110

111-
// Heartbeat updates the agent's heartbeat timestamp.
112-
func (s *Service) Heartbeat(ctx context.Context, agentID string, status agentcard.AgentStatus) (time.Time, error) {
111+
// Heartbeat updates the agent's heartbeat timestamp and optionally merges metadata.
112+
func (s *Service) Heartbeat(ctx context.Context, agentID string, status agentcard.AgentStatus, metadata map[string]string) (time.Time, error) {
113113
if status == "" {
114114
status = agentcard.StatusOnline
115115
}
116116
if err := s.store.UpdateHeartbeat(ctx, agentID, status); err != nil {
117117
return time.Time{}, fmt.Errorf("heartbeat: %w", err)
118118
}
119+
120+
// Merge metadata if provided, but prevent overwriting ownership.
121+
if len(metadata) > 0 {
122+
delete(metadata, "owner_user_id")
123+
if err := s.store.UpdateMetadata(ctx, agentID, metadata); err != nil {
124+
s.logger.Warn("failed to update heartbeat metadata", "agent_id", agentID, "error", err)
125+
}
126+
}
127+
119128
// Next heartbeat expected within the configured interval.
120129
interval := s.HeartbeatInterval
121130
if interval <= 0 {

internal/registry/service_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ func TestService_Heartbeat(t *testing.T) {
9191
Protocols: []protocol.Protocol{protocol.ProtocolA2A},
9292
})
9393

94-
deadline, err := svc.Heartbeat(ctx, card.ID, agentcard.StatusOnline)
94+
deadline, err := svc.Heartbeat(ctx, card.ID, agentcard.StatusOnline, nil)
9595
if err != nil {
9696
t.Fatalf("Heartbeat: %v", err)
9797
}

internal/registry/sqlite.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,26 @@ func (s *SQLiteStore) UpdateHeartbeat(ctx context.Context, id string, status age
281281
return nil
282282
}
283283

284+
func (s *SQLiteStore) UpdateMetadata(ctx context.Context, id string, metadata map[string]string) error {
285+
if len(metadata) == 0 {
286+
return nil
287+
}
288+
// Read existing metadata, merge, write back.
289+
var existing string
290+
err := s.db.QueryRowContext(ctx, "SELECT COALESCE(metadata, '{}') FROM agents WHERE id = ?", id).Scan(&existing)
291+
if err != nil {
292+
return fmt.Errorf("agent %s not found", id)
293+
}
294+
merged := map[string]string{}
295+
_ = json.Unmarshal([]byte(existing), &merged)
296+
for k, v := range metadata {
297+
merged[k] = v
298+
}
299+
data, _ := json.Marshal(merged)
300+
_, err = s.db.ExecContext(ctx, "UPDATE agents SET metadata = ? WHERE id = ?", string(data), id)
301+
return err
302+
}
303+
284304
func (s *SQLiteStore) FindByCapabilities(ctx context.Context, capabilities []string, proto string, maxResults int) ([]*agentcard.Card, error) {
285305
var conditions []string
286306
var args []any

internal/registry/store.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,9 @@ type Store interface {
5555
// UpdateHeartbeat updates the heartbeat timestamp and status of an agent.
5656
UpdateHeartbeat(ctx context.Context, id string, status agentcard.AgentStatus) error
5757

58+
// UpdateMetadata merges the provided metadata keys into the agent's existing metadata.
59+
UpdateMetadata(ctx context.Context, id string, metadata map[string]string) error
60+
5861
// FindByCapabilities returns agents that match any of the given capabilities.
5962
FindByCapabilities(ctx context.Context, capabilities []string, protocol string, maxResults int) ([]*agentcard.Card, error)
6063

internal/server/admin_handler.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -555,6 +555,22 @@ func (s *HTTPServer) registerAdminRoutes() {
555555

556556
// Invocation log.
557557
s.mux.Handle("GET /api/v1/admin/invocations", wrapAdmin(s.handleAdminListInvocations))
558+
559+
// SDK version check.
560+
s.mux.Handle("GET /api/v1/admin/sdk-version", wrapAdmin(s.handleAdminSDKVersion))
561+
}
562+
563+
// handleAdminSDKVersion handles GET /api/v1/admin/sdk-version.
564+
func (s *HTTPServer) handleAdminSDKVersion(w http.ResponseWriter, r *http.Request) {
565+
if s.versionCheck == nil {
566+
s.jsonError(w, "version check not enabled", http.StatusNotImplemented)
567+
return
568+
}
569+
latest, releaseURL := s.versionCheck.Latest()
570+
s.jsonResponse(w, http.StatusOK, map[string]any{
571+
"latest": latest,
572+
"release_url": releaseURL,
573+
})
558574
}
559575

560576
// queryInt extracts an integer query parameter with a default value.

internal/server/http.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import (
2626
"github.com/peerclaw/peerclaw-server/internal/invocation"
2727
"github.com/peerclaw/peerclaw-server/internal/review"
2828
"github.com/peerclaw/peerclaw-server/internal/userauth"
29+
"github.com/peerclaw/peerclaw-server/internal/versioncheck"
2930
"github.com/peerclaw/peerclaw-server/internal/verification"
3031
"go.opentelemetry.io/otel/trace"
3132
)
@@ -69,6 +70,7 @@ type HTTPServer struct {
6970
a2aTasks *a2aBridgeTasks
7071
acpRuns *acpBridgeRuns
7172
mcpSessions *mcpBridgeSessions
73+
versionCheck *versioncheck.Service
7274
cleanupCancel context.CancelFunc // cancels bridge cleanup goroutines
7375
}
7476

@@ -202,6 +204,11 @@ func (s *HTTPServer) SetBridgeRateLimiter(rl *IPRateLimiter) {
202204
s.bridgeRateLimiter = rl
203205
}
204206

207+
// SetVersionCheck sets the version check service for SDK upgrade prompts.
208+
func (s *HTTPServer) SetVersionCheck(vc *versioncheck.Service) {
209+
s.versionCheck = vc
210+
}
211+
205212
// UserACLChecker checks whether a user has access to an agent.
206213
type UserACLChecker interface {
207214
IsAllowed(ctx context.Context, agentID, userID string) (bool, error)
@@ -565,7 +572,7 @@ func (s *HTTPServer) handleHeartbeat(w http.ResponseWriter, r *http.Request) {
565572
status = agentcard.StatusOnline
566573
}
567574

568-
deadline, err := s.registry.Heartbeat(r.Context(), id, status)
575+
deadline, err := s.registry.Heartbeat(r.Context(), id, status, req.Metadata)
569576
if err != nil {
570577
s.jsonError(w, err.Error(), http.StatusNotFound)
571578
return
@@ -752,6 +759,7 @@ func (s *HTTPServer) registerProviderRoutes() {
752759
s.mux.Handle("GET /api/v1/provider/agents/{id}/analytics", s.wrapUserAuth(s.handleProviderAgentAnalytics))
753760
s.mux.Handle("GET /api/v1/provider/dashboard", s.wrapUserAuth(s.handleProviderDashboard))
754761
s.mux.Handle("GET /api/v1/provider/directory", s.wrapUserAuth(s.handleConsoleDirectory))
762+
s.mux.Handle("GET /api/v1/provider/sdk-version", s.wrapUserAuth(s.handleProviderSDKVersion))
755763
}
756764

757765
func (s *HTTPServer) registerClaimTokenRoutes() {

internal/server/provider_handler.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,13 @@ func (s *HTTPServer) handleProviderGetAgent(w http.ResponseWriter, r *http.Reque
188188
resp["visibility"] = flags.Visibility
189189
}
190190

191+
// Expose sdk_version from metadata.
192+
if card.Metadata != nil {
193+
if v, ok := card.Metadata["sdk_version"]; ok {
194+
resp["sdk_version"] = v
195+
}
196+
}
197+
191198
// Enrich with live reputation score and verified status.
192199
if s.reputation != nil {
193200
score, _ := s.reputation.GetScore(r.Context(), card.ID)
@@ -498,6 +505,19 @@ func (s *HTTPServer) handleProviderDashboard(w http.ResponseWriter, r *http.Requ
498505
})
499506
}
500507

508+
// handleProviderSDKVersion handles GET /api/v1/provider/sdk-version.
509+
func (s *HTTPServer) handleProviderSDKVersion(w http.ResponseWriter, r *http.Request) {
510+
if s.versionCheck == nil {
511+
s.jsonError(w, "version check not enabled", http.StatusNotImplemented)
512+
return
513+
}
514+
latest, releaseURL := s.versionCheck.Latest()
515+
s.jsonResponse(w, http.StatusOK, map[string]any{
516+
"latest": latest,
517+
"release_url": releaseURL,
518+
})
519+
}
520+
501521
// handleConsoleDirectory handles GET /api/v1/provider/directory — console directory search.
502522
// Unlike the public directory, this includes the user's own private agents.
503523
func (s *HTTPServer) handleConsoleDirectory(w http.ResponseWriter, r *http.Request) {

0 commit comments

Comments
 (0)