Skip to content
Open
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
56 changes: 56 additions & 0 deletions internal/cli/status.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package cli

import (
"fmt"

"github.com/agentregistry-dev/agentregistry/internal/client"
"github.com/agentregistry-dev/agentregistry/internal/version"
"github.com/agentregistry-dev/agentregistry/pkg/printer"
"github.com/spf13/cobra"
)

// StatusCmd shows the status of the daemon and database connectivity.
var StatusCmd = &cobra.Command{
Use: "status",
Short: "Show status of the daemon and database",
Long: `Displays the current status of the AgentRegistry daemon, database connectivity, and server version.`,
RunE: func(cmd *cobra.Command, args []string) error {
return runStatus()
},
}

func runStatus() error {
baseURL := client.DefaultBaseURL
if apiClient != nil {
baseURL = apiClient.BaseURL
}

c := apiClient
if c == nil {
c = client.NewClient(baseURL, "")
}

printer.PrintInfo(fmt.Sprintf("arctl version %s", version.Version))
printer.PrintInfo("")

health, err := c.CheckHealth()
if err != nil {
printer.PrintFailure(fmt.Sprintf("Daemon is not running (%s)", c.BaseURL))
printer.PrintFailure("Database is not healthy")
return nil
}

if health.Status == "ok" {
printer.PrintSuccess(fmt.Sprintf("Daemon is running (%s)", c.BaseURL))
} else {
printer.PrintWarning(fmt.Sprintf("Daemon is degraded (%s)", c.BaseURL))
}

if health.Database == "ok" {
printer.PrintSuccess("Database is healthy")
} else {
printer.PrintFailure(fmt.Sprintf("Database is not healthy: %s", health.Database))
}

return nil
}
19 changes: 19 additions & 0 deletions internal/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,25 @@ func (c *Client) Ping() error {
return c.doJSON(req, nil)
}

// HealthResponse represents the response from the /health endpoint.
type HealthResponse struct {
Status string `json:"status"`
Database string `json:"database"`
}

// CheckHealth queries the /health endpoint and returns structured health status.
func (c *Client) CheckHealth() (*HealthResponse, error) {
req, err := c.newRequest(http.MethodGet, "/health")
if err != nil {
return nil, err
}
var resp HealthResponse
if err := c.doJSON(req, &resp); err != nil {
return nil, err
}
return &resp, nil
}

func (c *Client) GetVersion() (*VersionBody, error) {
req, err := c.newRequest(http.MethodGet, "/version")
if err != nil {
Expand Down
19 changes: 16 additions & 3 deletions internal/registry/api/handlers/v0/health.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package v0

import (
"context"
"log/slog"
"net/http"
"strings"

Expand All @@ -10,32 +11,44 @@ import (
"go.opentelemetry.io/otel/metric"

"github.com/agentregistry-dev/agentregistry/internal/registry/config"
"github.com/agentregistry-dev/agentregistry/internal/registry/service"
"github.com/agentregistry-dev/agentregistry/internal/registry/telemetry"
"github.com/agentregistry-dev/agentregistry/pkg/types"
)

// HealthBody represents the health check response body
type HealthBody struct {
Status string `json:"status" example:"ok" doc:"Health status"`
Database string `json:"database" example:"ok" doc:"Database connectivity status"`
GitHubClientID string `json:"github_client_id,omitempty" doc:"GitHub OAuth App Client ID"`
}

// RegisterHealthEndpoint registers the health check endpoint with a custom path prefix
func RegisterHealthEndpoint(api huma.API, pathPrefix string, cfg *config.Config, metrics *telemetry.Metrics) {
func RegisterHealthEndpoint(api huma.API, pathPrefix string, cfg *config.Config, metrics *telemetry.Metrics, registry service.RegistryService) {
huma.Register(api, huma.Operation{
OperationID: "get-health" + strings.ReplaceAll(pathPrefix, "/", "-"),
Method: http.MethodGet,
Path: pathPrefix + "/health",
Summary: "Health check",
Description: "Check the health status of the API",
Description: "Check the health status of the API and its dependencies",
Tags: []string{"health"},
}, func(ctx context.Context, _ *struct{}) (*types.Response[HealthBody], error) {
// Record the health check metrics
recordHealthMetrics(ctx, metrics, pathPrefix+"/health", cfg.Version)

status := "ok"
dbStatus := "ok"

if err := registry.PingDB(ctx); err != nil {
slog.Warn("health check: database ping failed", "error", err)
status = "degraded"
dbStatus = "unavailable"
}

return &types.Response[HealthBody]{
Body: HealthBody{
Status: "ok",
Status: status,
Database: dbStatus,
GitHubClientID: cfg.GithubClientID,
},
}, nil
Expand Down
32 changes: 25 additions & 7 deletions internal/registry/api/handlers/v0/health_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package v0_test

import (
"context"
"errors"
"net/http"
"net/http/httptest"
"testing"
Expand All @@ -12,14 +13,15 @@ import (

v0 "github.com/agentregistry-dev/agentregistry/internal/registry/api/handlers/v0"
"github.com/agentregistry-dev/agentregistry/internal/registry/config"
servicetesting "github.com/agentregistry-dev/agentregistry/internal/registry/service/testing"
"github.com/agentregistry-dev/agentregistry/internal/registry/telemetry"
)

func TestHealthEndpoint(t *testing.T) {
// Test cases
testCases := []struct {
name string
config *config.Config
pingErr error
expectedStatus int
expectedBody v0.HealthBody
}{
Expand All @@ -31,6 +33,7 @@ func TestHealthEndpoint(t *testing.T) {
expectedStatus: http.StatusOK,
expectedBody: v0.HealthBody{
Status: "ok",
Database: "ok",
GitHubClientID: "test-github-client-id",
},
},
Expand All @@ -41,22 +44,36 @@ func TestHealthEndpoint(t *testing.T) {
},
expectedStatus: http.StatusOK,
expectedBody: v0.HealthBody{
Status: "ok",
GitHubClientID: "",
Status: "ok",
Database: "ok",
},
},
{
name: "returns degraded when database is unavailable",
config: &config.Config{
GithubClientID: "",
},
pingErr: errors.New("connection refused"),
expectedStatus: http.StatusOK,
expectedBody: v0.HealthBody{
Status: "degraded",
Database: "unavailable",
},
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Create a new test API
mux := http.NewServeMux()
api := humago.New(mux, huma.DefaultConfig("Test API", "1.0.0"))

shutdownTelemetry, metrics, _ := telemetry.InitMetrics("test")

// Register the health endpoint
v0.RegisterHealthEndpoint(api, "/v0", tc.config, metrics)
fakeRegistry := servicetesting.NewFakeRegistry()
if tc.pingErr != nil {
fakeRegistry.PingDBFn = func(_ context.Context) error { return tc.pingErr }
}
v0.RegisterHealthEndpoint(api, "/v0", tc.config, metrics, fakeRegistry)

// Create a test request
req := httptest.NewRequest(http.MethodGet, "/v0/health", nil)
Expand All @@ -74,7 +91,8 @@ func TestHealthEndpoint(t *testing.T) {
// Check the response body
// Since Huma adds a $schema field, we'll check individual fields
body := w.Body.String()
assert.Contains(t, body, `"status":"ok"`)
assert.Contains(t, body, `"status":"`+tc.expectedBody.Status+`"`)
assert.Contains(t, body, `"database":"`+tc.expectedBody.Database+`"`)

if tc.config.GithubClientID != "" {
assert.Contains(t, body, `"github_client_id":"test-github-client-id"`)
Expand Down
2 changes: 1 addition & 1 deletion internal/registry/api/handlers/v0/telemetry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ func TestPrometheusHandler(t *testing.T) {
api.UseMiddleware(router.MetricTelemetryMiddleware(metrics,
router.WithSkipPaths("/health", "/metrics", "/ping", "/docs"),
))
v0.RegisterHealthEndpoint(api, "/v0", cfg, metrics)
v0.RegisterHealthEndpoint(api, "/v0", cfg, metrics, registryService)
v0.RegisterServersEndpoints(api, "/v0", registryService)

// Add /metrics for Prometheus metrics using promhttp
Expand Down
2 changes: 1 addition & 1 deletion internal/registry/api/router/v0.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ func RegisterRoutes(
) {
pathPrefix := "/v0"

v0.RegisterHealthEndpoint(api, pathPrefix, cfg, metrics)
v0.RegisterHealthEndpoint(api, pathPrefix, cfg, metrics, registry)
Copy link
Collaborator

@inFocus7 inFocus7 Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note: passing registry so it does registry.db.PingDB() for db health

v0.RegisterPingEndpoint(api, pathPrefix)
v0.RegisterVersionEndpoint(api, pathPrefix, versionInfo)
v0.RegisterServersEndpoints(api, pathPrefix, registry)
Expand Down
5 changes: 5 additions & 0 deletions internal/registry/database/postgres.go
Original file line number Diff line number Diff line change
Expand Up @@ -3457,6 +3457,11 @@ func (db *PostgreSQL) DeletePrompt(ctx context.Context, tx pgx.Tx, promptName, v
return nil
}

// Ping verifies database connectivity with a lightweight round-trip.
func (db *PostgreSQL) Ping(ctx context.Context) error {
return db.pool.Ping(ctx)
}

// Close closes the database connection
func (db *PostgreSQL) Close() error {
db.pool.Close()
Expand Down
5 changes: 5 additions & 0 deletions internal/registry/service/registry_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,11 @@ func NewRegistryService(
}
}

// PingDB verifies database connectivity delegating to the database layer.
func (s *registryServiceImpl) PingDB(ctx context.Context) error {
return s.db.Ping(ctx)
}

// SetPlatformAdapters wires platform extension adapters into the service.
func (s *registryServiceImpl) SetPlatformAdapters(
deploymentPlatforms map[string]registrytypes.DeploymentPlatformAdapter,
Expand Down
2 changes: 2 additions & 0 deletions internal/registry/service/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import (

// RegistryService defines the interface for registry operations
type RegistryService interface {
// PingDB verifies database connectivity
PingDB(ctx context.Context) error
// ListServers retrieve all servers with optional filtering
ListServers(ctx context.Context, filter *database.ServerFilter, cursor string, limit int) ([]*apiv0.ServerResponse, string, error)
// GetServerByName retrieve latest version of a server by server name
Expand Down
8 changes: 8 additions & 0 deletions internal/registry/service/testing/fake_registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ type FakeRegistry struct {
GetDeploymentLogsFn func(ctx context.Context, deployment *models.Deployment) ([]string, error)
CancelDeploymentFn func(ctx context.Context, deployment *models.Deployment) error
ReconcileAllFn func(ctx context.Context) error
PingDBFn func(ctx context.Context) error

// Prompt fields and hooks
Prompts []*models.PromptResponse
Expand Down Expand Up @@ -511,6 +512,13 @@ func (f *FakeRegistry) DeletePrompt(ctx context.Context, promptName, version str
return database.ErrNotFound
}

func (f *FakeRegistry) PingDB(ctx context.Context) error {
if f.PingDBFn != nil {
return f.PingDBFn(ctx)
}
return nil
}

func (f *FakeRegistry) ReconcileAll(ctx context.Context) error {
if f.ReconcileAllFn != nil {
return f.ReconcileAllFn(ctx)
Expand Down
8 changes: 7 additions & 1 deletion openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -558,7 +558,7 @@ paths:
tags:
- health
summary: Health check
description: Check the health status of the API
description: Check the health status of the API and its dependencies
operationId: get-health-v0
responses:
"200":
Expand Down Expand Up @@ -1992,6 +1992,11 @@ components:
type: object
additionalProperties: false
properties:
database:
type: string
description: Database connectivity status
examples:
- ok
github_client_id:
type: string
description: GitHub OAuth App Client ID
Expand All @@ -2002,6 +2007,7 @@ components:
- ok
required:
- status
- database
Icon:
type: object
additionalProperties: false
Expand Down
1 change: 1 addition & 0 deletions pkg/cli/commands_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ func TestCommandTree(t *testing.T) {
"mcp",
"prompt",
"skill",
"status",
"version",
}

Expand Down
2 changes: 2 additions & 0 deletions pkg/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ func init() {
rootCmd.AddCommand(prompt.PromptCmd)
rootCmd.AddCommand(configure.ConfigureCmd)
rootCmd.AddCommand(cli.VersionCmd)
rootCmd.AddCommand(cli.StatusCmd)
rootCmd.AddCommand(cli.ImportCmd)
rootCmd.AddCommand(cli.ExportCmd)
rootCmd.AddCommand(cli.EmbeddingsCmd)
Expand Down Expand Up @@ -176,6 +177,7 @@ var preRunDaemonBehavior = struct {
"agent": {"init": true},
"mcp": {"init": true},
"skill": {"init": true},
"arctl": {"status": true},
},
}

Expand Down
6 changes: 6 additions & 0 deletions pkg/cli/root_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,10 @@ func TestPreRunBehavior(t *testing.T) {
listCmd := &cobra.Command{Use: "list"}
agentCmd.AddCommand(listCmd)

arctlCmd := &cobra.Command{Use: "arctl"}
statusCmd := &cobra.Command{Use: "status"}
arctlCmd.AddCommand(statusCmd)

skillCmd := &cobra.Command{Use: "skill"}
skillInitCmd := &cobra.Command{Use: "init"}
skillCmd.AddCommand(skillInitCmd)
Expand All @@ -98,6 +102,8 @@ func TestPreRunBehavior(t *testing.T) {
{"mcp init", mcpInitCmd, "http://localhost:12121", true, false},
{"skill init", skillInitCmd, "http://localhost:12121", true, false},
{"mcp init python (subcommand of init)", initPythonCmd, "http://localhost:12121", true, false},
// Status command: skip setup entirely (creates its own client internally)
{"status localhost:12121", statusCmd, "http://localhost:12121", true, false},
// No skip for other commands; auto-start depends on URL
{"agent list localhost:12121", listCmd, "http://localhost:12121", false, true},
{"agent list other port", listCmd, "http://localhost:8080", false, false},
Expand Down
7 changes: 7 additions & 0 deletions pkg/printer/printer.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,13 @@ func PrintError(message string) {
fmt.Fprintf(os.Stderr, "Error: %s\n", message)
}

// PrintFailure prints a negative-status message with a cross-mark prefix to stdout.
// Use for reporting expected negative conditions (e.g. "daemon is not running")
// rather than PrintError which implies an unexpected error.
func PrintFailure(message string) {
_, _ = fmt.Fprintf(os.Stdout, "✗ %s\n", message)
}

// PrintWarning prints a warning message
func PrintWarning(message string) {
_, _ = fmt.Fprintf(os.Stdout, "Warning: %s\n", message)
Expand Down
2 changes: 2 additions & 0 deletions pkg/registry/database/database.go
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,8 @@ type Database interface {
GetServerReadme(ctx context.Context, tx pgx.Tx, serverName, version string) (*ServerReadme, error)
// GetLatestServerReadme retrieves the README blob for the latest server version
GetLatestServerReadme(ctx context.Context, tx pgx.Tx, serverName string) (*ServerReadme, error)
// Ping verifies database connectivity with a lightweight round-trip
Ping(ctx context.Context) error
// InTransaction executes a function within a database transaction
InTransaction(ctx context.Context, fn func(ctx context.Context, tx pgx.Tx) error) error
// Close closes the database connection
Expand Down
Loading
Loading