diff --git a/README.md b/README.md index 26e1290..d1add48 100644 --- a/README.md +++ b/README.md @@ -281,7 +281,14 @@ When enabled, PicoClaw exposes: - `GET /api/v1/status` — Node status - `POST /api/v1/command` — Receive commands from fleet -And periodically sends heartbeats (including gene stats) to the configured fleet manager. +And periodically reports to the configured fleet manager: +- `POST /fleet/register` - node id, name, capabilities, version, and timestamp +- `POST /fleet/heartbeat` - online status plus gene stats when the Gene Evolution engine is enabled +- `POST /fleet/status` - structured status snapshots for operational state +- `POST /fleet/events` - one-off edge events +- `POST /fleet/genes/publish` - high-confidence gene sharing + +If `cloud_token` is set, reporter requests include `Authorization: Bearer `. ## Skills (6 built-in) diff --git a/pkg/edge/reporter.go b/pkg/edge/reporter.go index 48f06a8..801ddba 100644 --- a/pkg/edge/reporter.go +++ b/pkg/edge/reporter.go @@ -4,8 +4,11 @@ import ( "bytes" "encoding/json" "fmt" + "io" "log" "net/http" + "strings" + "sync" "time" ) @@ -19,10 +22,12 @@ type GeneStatsProvider interface { // Reporter periodically sends heartbeat and event reports // to the upstream Fleet Manager (L2 NanoClaw or L3 MoltClaw). type Reporter struct { - config Config - client *http.Client - stopCh chan struct{} - geneProvider GeneStatsProvider + config Config + client *http.Client + stopCh chan struct{} + stopOnce sync.Once + geneProvider GeneStatsProvider + now func() time.Time } // NewReporter creates a new Edge Reporter. @@ -31,24 +36,43 @@ func NewReporter(cfg Config) *Reporter { config: cfg, client: &http.Client{Timeout: 10 * time.Second}, stopCh: make(chan struct{}), + now: time.Now, + } +} + +// SetHTTPClient overrides the reporter HTTP client. It is primarily useful for +// tests and embedders that need custom transport behavior. +func (r *Reporter) SetHTTPClient(client *http.Client) { + if client != nil { + r.client = client } } // StartHeartbeat begins the periodic heartbeat loop. func (r *Reporter) StartHeartbeat() { - ticker := time.NewTicker(r.config.Cloud.HeartbeatInterval) + interval := r.config.Cloud.HeartbeatInterval + if interval <= 0 { + interval = 30 * time.Second + } + ticker := time.NewTicker(interval) defer ticker.Stop() log.Printf("[edge] heartbeat reporter started (interval: %s, endpoint: %s)", - r.config.Cloud.HeartbeatInterval, r.config.Cloud.Endpoint) + interval, r.config.Cloud.Endpoint) // Send initial registration - r.sendRegistration() + if err := r.Register(); err != nil { + log.Printf("[edge] registration failed: %v", err) + } else { + log.Printf("[edge] registered with Fleet Manager as %s", r.config.NodeID) + } for { select { case <-ticker.C: - r.sendHeartbeat() + if err := r.SendHeartbeat(); err != nil { + log.Printf("[edge] heartbeat failed: %v", err) + } case <-r.stopCh: log.Println("[edge] heartbeat reporter stopped") return @@ -58,7 +82,9 @@ func (r *Reporter) StartHeartbeat() { // Stop halts the heartbeat reporter. func (r *Reporter) Stop() { - close(r.stopCh) + r.stopOnce.Do(func() { + close(r.stopCh) + }) } // SetGeneProvider attaches a gene stats provider to include @@ -73,42 +99,44 @@ func (r *Reporter) PublishGene(geneData map[string]interface{}) error { payload := map[string]any{ "node_id": r.config.NodeID, "gene": geneData, - "timestamp": time.Now().UTC().Format(time.RFC3339), + "timestamp": r.timestamp(), } return r.post("/fleet/genes/publish", payload) } // ReportEvent sends a one-off event to the Fleet Manager. func (r *Reporter) ReportEvent(eventType string, data map[string]any) error { + if strings.TrimSpace(eventType) == "" { + return fmt.Errorf("event type is required") + } payload := map[string]any{ "node_id": r.config.NodeID, "type": eventType, "data": data, - "timestamp": time.Now().UTC().Format(time.RFC3339), + "timestamp": r.timestamp(), } return r.post("/fleet/events", payload) } -func (r *Reporter) sendRegistration() { +// Register sends an initial node registration report to the Fleet Manager. +func (r *Reporter) Register() error { payload := map[string]any{ "node_id": r.config.NodeID, "node_name": r.config.NodeName, "type": "picclaw", "capabilities": []string{"sensor", "exec", "cron", "alert"}, "version": "0.1.0", + "timestamp": r.timestamp(), } - if err := r.post("/fleet/register", payload); err != nil { - log.Printf("[edge] registration failed: %v", err) - } else { - log.Printf("[edge] registered with Fleet Manager as %s", r.config.NodeID) - } + return r.post("/fleet/register", payload) } -func (r *Reporter) sendHeartbeat() { +// SendHeartbeat reports that this edge node is online. +func (r *Reporter) SendHeartbeat() error { payload := map[string]any{ "node_id": r.config.NodeID, "status": "online", - "timestamp": time.Now().UTC().Format(time.RFC3339), + "timestamp": r.timestamp(), } // Include gene evolution stats if provider is available @@ -116,9 +144,22 @@ func (r *Reporter) sendHeartbeat() { payload["gene_stats"] = r.geneProvider.GetStats() } - if err := r.post("/fleet/heartbeat", payload); err != nil { - log.Printf("[edge] heartbeat failed: %v", err) + return r.post("/fleet/heartbeat", payload) +} + +// ReportStatus sends a structured status snapshot to the Fleet Manager. +func (r *Reporter) ReportStatus(status string, data map[string]any) error { + if strings.TrimSpace(status) == "" { + status = "unknown" } + payload := map[string]any{ + "node_id": r.config.NodeID, + "node_name": r.config.NodeName, + "status": status, + "data": data, + "timestamp": r.timestamp(), + } + return r.post("/fleet/status", payload) } func (r *Reporter) post(path string, payload any) error { @@ -127,7 +168,7 @@ func (r *Reporter) post(path string, payload any) error { return fmt.Errorf("marshal: %w", err) } - url := r.config.Cloud.Endpoint + path + url := strings.TrimRight(r.config.Cloud.Endpoint, "/") + path req, err := http.NewRequest("POST", url, bytes.NewReader(body)) if err != nil { return fmt.Errorf("request: %w", err) @@ -144,7 +185,15 @@ func (r *Reporter) post(path string, payload any) error { defer resp.Body.Close() if resp.StatusCode >= 300 { + detail, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + if len(bytes.TrimSpace(detail)) > 0 { + return fmt.Errorf("post %s: status %d: %s", path, resp.StatusCode, strings.TrimSpace(string(detail))) + } return fmt.Errorf("post %s: status %d", path, resp.StatusCode) } return nil -} \ No newline at end of file +} + +func (r *Reporter) timestamp() string { + return r.now().UTC().Format(time.RFC3339) +} diff --git a/pkg/edge/reporter_test.go b/pkg/edge/reporter_test.go new file mode 100644 index 0000000..3176f2b --- /dev/null +++ b/pkg/edge/reporter_test.go @@ -0,0 +1,204 @@ +package edge + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +type requestRecord struct { + Path string + Authorization string + Payload map[string]any +} + +type fakeGeneProvider struct{} + +func (fakeGeneProvider) GetStats() map[string]interface{} { + return map[string]interface{}{"genes": 3, "capsules": 2} +} + +func (fakeGeneProvider) GetHighConfidenceGenes(float64, int) []map[string]interface{} { + return []map[string]interface{}{{"id": "gene-a"}} +} + +func newTestReporter(serverURL string) *Reporter { + reporter := NewReporter(Config{ + NodeID: "node-1", + NodeName: "pond-edge", + Cloud: CloudConfig{ + Endpoint: serverURL, + Token: "secret-token", + HeartbeatInterval: time.Second, + }, + }) + reporter.now = func() time.Time { + return time.Date(2026, 5, 31, 12, 0, 0, 0, time.UTC) + } + return reporter +} + +func captureServer(t *testing.T) (*httptest.Server, *[]requestRecord) { + t.Helper() + records := []requestRecord{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("method = %s, want POST", r.Method) + } + if got := r.Header.Get("Content-Type"); got != "application/json" { + t.Errorf("content type = %s, want application/json", got) + } + + var payload map[string]any + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Errorf("decode request body: %v", err) + } + records = append(records, requestRecord{ + Path: r.URL.Path, + Authorization: r.Header.Get("Authorization"), + Payload: payload, + }) + w.WriteHeader(http.StatusAccepted) + })) + return server, &records +} + +func TestRegisterPostsNodeMetadataWithAuthorization(t *testing.T) { + server, records := captureServer(t) + defer server.Close() + + reporter := newTestReporter(server.URL + "/") + + if err := reporter.Register(); err != nil { + t.Fatalf("Register() error = %v", err) + } + + if len(*records) != 1 { + t.Fatalf("records = %d, want 1", len(*records)) + } + record := (*records)[0] + if record.Path != "/fleet/register" { + t.Fatalf("path = %s, want /fleet/register", record.Path) + } + if record.Authorization != "Bearer secret-token" { + t.Fatalf("authorization = %q", record.Authorization) + } + if record.Payload["node_id"] != "node-1" || record.Payload["node_name"] != "pond-edge" { + t.Fatalf("payload missing node metadata: %#v", record.Payload) + } + if record.Payload["timestamp"] != "2026-05-31T12:00:00Z" { + t.Fatalf("timestamp = %#v", record.Payload["timestamp"]) + } +} + +func TestSendHeartbeatIncludesGeneStats(t *testing.T) { + server, records := captureServer(t) + defer server.Close() + + reporter := newTestReporter(server.URL) + reporter.SetGeneProvider(fakeGeneProvider{}) + + if err := reporter.SendHeartbeat(); err != nil { + t.Fatalf("SendHeartbeat() error = %v", err) + } + + record := (*records)[0] + if record.Path != "/fleet/heartbeat" { + t.Fatalf("path = %s, want /fleet/heartbeat", record.Path) + } + if record.Payload["status"] != "online" { + t.Fatalf("status = %#v", record.Payload["status"]) + } + stats, ok := record.Payload["gene_stats"].(map[string]any) + if !ok { + t.Fatalf("gene_stats missing or wrong type: %#v", record.Payload["gene_stats"]) + } + if stats["genes"].(float64) != 3 { + t.Fatalf("gene_stats.genes = %#v", stats["genes"]) + } +} + +func TestReportStatusAndEvent(t *testing.T) { + server, records := captureServer(t) + defer server.Close() + + reporter := newTestReporter(server.URL) + + if err := reporter.ReportStatus("degraded", map[string]any{"queue_depth": 4}); err != nil { + t.Fatalf("ReportStatus() error = %v", err) + } + if err := reporter.ReportEvent("sensor.alert", map[string]any{"sensor": "temp"}); err != nil { + t.Fatalf("ReportEvent() error = %v", err) + } + + status := (*records)[0] + if status.Path != "/fleet/status" { + t.Fatalf("status path = %s", status.Path) + } + if status.Payload["status"] != "degraded" { + t.Fatalf("status payload = %#v", status.Payload) + } + + event := (*records)[1] + if event.Path != "/fleet/events" { + t.Fatalf("event path = %s", event.Path) + } + if event.Payload["type"] != "sensor.alert" { + t.Fatalf("event type = %#v", event.Payload["type"]) + } +} + +func TestPublishGenePostsGenePayload(t *testing.T) { + server, records := captureServer(t) + defer server.Close() + + reporter := newTestReporter(server.URL) + + if err := reporter.PublishGene(map[string]interface{}{"id": "gene-a"}); err != nil { + t.Fatalf("PublishGene() error = %v", err) + } + + record := (*records)[0] + if record.Path != "/fleet/genes/publish" { + t.Fatalf("path = %s, want /fleet/genes/publish", record.Path) + } + gene, ok := record.Payload["gene"].(map[string]any) + if !ok || gene["id"] != "gene-a" { + t.Fatalf("gene payload = %#v", record.Payload["gene"]) + } +} + +func TestPostReturnsFailureDetail(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "bad token", http.StatusUnauthorized) + })) + defer server.Close() + + reporter := newTestReporter(server.URL) + + err := reporter.ReportEvent("sensor.alert", nil) + if err == nil { + t.Fatal("ReportEvent() error = nil, want failure") + } + if !strings.Contains(err.Error(), "status 401") || !strings.Contains(err.Error(), "bad token") { + t.Fatalf("error = %q, want status and response body", err.Error()) + } +} + +func TestReportEventRejectsEmptyType(t *testing.T) { + reporter := newTestReporter("http://example.test") + + if err := reporter.ReportEvent(" ", nil); err == nil { + t.Fatal("ReportEvent() error = nil, want validation error") + } +} + +func TestStopIsIdempotent(t *testing.T) { + reporter := newTestReporter("http://example.test") + + reporter.Stop() + reporter.Stop() +} diff --git a/pkg/gene/selector.go b/pkg/gene/selector.go index 40e31ef..fa14fe2 100644 --- a/pkg/gene/selector.go +++ b/pkg/gene/selector.go @@ -83,7 +83,13 @@ func SelectGenes(genes []Gene, signals []string, preset StrategyPreset, maxResul // Sort by score descending sort.Slice(candidates, func(i, j int) bool { - return candidates[i].Score > candidates[j].Score + if candidates[i].Score != candidates[j].Score { + return candidates[i].Score > candidates[j].Score + } + if candidates[i].Gene.Confidence != candidates[j].Gene.Confidence { + return candidates[i].Gene.Confidence > candidates[j].Gene.Confidence + } + return candidates[i].Gene.VerifiedBy > candidates[j].Gene.VerifiedBy }) if maxResults > 0 && len(candidates) > maxResults {