Skip to content
Closed
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
15 changes: 15 additions & 0 deletions cmd/picoclaw/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import (
"github.com/sipeed/picoclaw/pkg/channels"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/cron"
"github.com/sipeed/picoclaw/pkg/health"
"github.com/sipeed/picoclaw/pkg/heartbeat"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/providers"
Expand Down Expand Up @@ -595,6 +596,20 @@ func gatewayCmd() {
fmt.Println("⚠ Warning: No channels enabled")
}

// Start health check server
healthServer := health.NewServer(health.Config{
Port: cfg.Gateway.HealthPort,
Version: version,
BuildTime: "unknown", // Can be set via ldflags
AgentName: "picclaw",
})
go func() {
if err := healthServer.Start(); err != nil {
fmt.Printf("Error starting health server: %v\n", err)
}
}()
fmt.Printf("✓ Health check endpoint: http://%s:%d/healthz\n", cfg.Gateway.Host, cfg.Gateway.HealthPort)

fmt.Printf("✓ Gateway started on %s:%d\n", cfg.Gateway.Host, cfg.Gateway.Port)
fmt.Println("Press Ctrl+C to stop")

Expand Down
184 changes: 184 additions & 0 deletions docs/health-check.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
# Health Check Endpoint

PicoClaw provides an HTTP health check endpoint for monitoring and orchestration.

## Endpoint

```
GET /healthz
```

## Configuration

Add to `config.json`:

```json
{
"gateway": {
"host": "0.0.0.0",
"port": 18790,
"health_port": 9090
}
}
```

Or via environment variable:

```bash
export PICOCLAW_GATEWAY_HEALTH_PORT=9090
```

## Response

```json
{
"status": "ok",
"uptime": "1h23m45s",
"version": "0.1.0",
"agent_name": "picclaw",
"go_version": "go1.24.0",
"build_time": "2026-02-16T08:00:00Z"
}
```

**HTTP 200 OK** - Agent is healthy and running

## Usage

### Local Development

```bash
# Start the gateway
picoclaw gateway

# Check health
curl http://localhost:9090/healthz
```

### Docker

```bash
# Build
docker build -t picclaw .

# Run with health check
docker run -d \
--name picclaw \
--health-cmd "curl -f http://localhost:9090/healthz || exit 1" \
--health-interval 30s \
--health-timeout 5s \
--health-retries 3 \
picclaw
```

### Kubernetes

```yaml
apiVersion: v1
kind: Pod
metadata:
name: picclaw
spec:
containers:
- name: picclaw
image: picclaw:latest
ports:
- containerPort: 9090
name: health
livenessProbe:
httpGet:
path: /healthz
port: 9090
initialDelaySeconds: 10
periodSeconds: 30
readinessProbe:
httpGet:
path: /healthz
port: 9090
initialDelaySeconds: 5
periodSeconds: 10
```

### systemd

```ini
[Unit]
Description=PicoClaw Agent
After=network.target

[Service]
ExecStart=/usr/local/bin/picoclaw gateway
ExecStartPost=/bin/sleep 2
ExecStartPost=/usr/bin/curl -f http://localhost:9090/healthz
Restart=always

[Install]
WantedBy=multi-user.target
```

## Testing

```bash
# Run tests
go test -v ./pkg/health

# With coverage
go test -v -cover ./pkg/health

# Expected output:
# === RUN TestNewServer
# --- PASS: TestNewServer (0.00s)
# === RUN TestHealthzEndpoint
# --- PASS: TestHealthzEndpoint (0.00s)
# ...
# PASS
# coverage: 100.0% of statements
```

## Monitoring

### Prometheus

```yaml
scrape_configs:
- job_name: 'picclaw'
metrics_path: '/healthz'
static_configs:
- targets: ['localhost:9090']
```

### Uptime Monitoring

Services like UptimeRobot, Pingdom, or Datadog can use `/healthz` for availability checks.

## Troubleshooting

### Port already in use

```bash
# Change port in config.json
{
"gateway": {
"health_port": 9091
}
}
```

### Health check fails

1. Check if gateway is running: `ps aux | grep picoclaw`
2. Check port binding: `netstat -tuln | grep 9090`
3. Check logs: `tail -f ~/.picoclaw/picoclaw.log`
4. Verify config: `cat ~/.picoclaw/config.json`

### Method not allowed (405)

Only `GET` requests are supported:

```bash
# ✅ Correct
curl -X GET http://localhost:9090/healthz

# ❌ Wrong
curl -X POST http://localhost:9090/healthz
```
10 changes: 6 additions & 4 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,8 +104,9 @@ type ProviderConfig struct {
}

type GatewayConfig struct {
Host string `json:"host" env:"PICOCLAW_GATEWAY_HOST"`
Port int `json:"port" env:"PICOCLAW_GATEWAY_PORT"`
Host string `json:"host" env:"PICOCLAW_GATEWAY_HOST"`
Port int `json:"port" env:"PICOCLAW_GATEWAY_PORT"`
HealthPort int `json:"health_port" env:"PICOCLAW_GATEWAY_HEALTH_PORT"`
}

type WebSearchConfig struct {
Expand Down Expand Up @@ -185,8 +186,9 @@ func DefaultConfig() *Config {
Gemini: ProviderConfig{},
},
Gateway: GatewayConfig{
Host: "0.0.0.0",
Port: 18790,
Host: "0.0.0.0",
Port: 18790,
HealthPort: 9090,
},
Tools: ToolsConfig{
Web: WebToolsConfig{
Expand Down
73 changes: 73 additions & 0 deletions pkg/health/server.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package health

import (
"encoding/json"
"fmt"
"net/http"
"runtime"
"time"
)

// Server provides HTTP health check endpoints
type Server struct {
port int
startTime time.Time
version string
buildTime string
agentName string
}

// Config holds health server configuration
type Config struct {
Port int
Version string
BuildTime string
AgentName string
}

// NewServer creates a new health check server
func NewServer(cfg Config) *Server {
return &Server{
port: cfg.Port,
startTime: time.Now(),
version: cfg.Version,
buildTime: cfg.BuildTime,
agentName: cfg.AgentName,
}
}

// Start begins listening on the configured port
func (s *Server) Start() error {
mux := http.NewServeMux()
mux.HandleFunc("/healthz", s.handleHealthz)

addr := fmt.Sprintf(":%d", s.port)
return http.ListenAndServe(addr, mux)
}

// handleHealthz returns health status
func (s *Server) handleHealthz(w http.ResponseWriter, r *http.Request) {
// Only accept GET requests
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}

uptime := time.Since(s.startTime)

response := map[string]interface{}{
"status": "ok",
"uptime": uptime.String(),
"version": s.version,
"agent_name": s.agentName,
"go_version": runtime.Version(),
"build_time": s.buildTime,
}

w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)

if err := json.NewEncoder(w).Encode(response); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
}
}
Loading