diff --git a/README.md b/README.md index 26e1290..b78b4a3 100644 --- a/README.md +++ b/README.md @@ -277,12 +277,15 @@ PicoClaw can act as an L1 edge node in a distributed architecture, reporting to ``` When enabled, PicoClaw exposes: -- `GET /healthz` — Health check -- `GET /api/v1/status` — Node status -- `POST /api/v1/command` — Receive commands from fleet +- `GET /api/health` and `GET /healthz` — Health check +- `GET /api/status` and `GET /api/v1/status` — Node status +- `POST /api/command` and `POST /api/v1/command` — Receive commands from fleet, queued on MessageBus +- `POST /api/message` — Receive user/operator messages, queued on MessageBus And periodically sends heartbeats (including gene stats) to the configured fleet manager. +An OpenAPI 3.0 specification is available at `pkg/edge/openapi.json`. + ## Skills (6 built-in) Skills are markdown files that teach the agent domain-specific knowledge. diff --git a/cmd/picoclaw/main.go b/cmd/picoclaw/main.go index 269f9b6..b1ec73b 100644 --- a/cmd/picoclaw/main.go +++ b/cmd/picoclaw/main.go @@ -588,7 +588,7 @@ func gatewayCmd() { Token: cfg.Edge.CloudToken, }, } - edgeServer = edge.NewServer(edgeCfg) + edgeServer = edge.NewServer(edgeCfg, msgBus) edgeReporter = edge.NewReporter(edgeCfg) // Wire gene engine into edge reporter for heartbeat stats diff --git a/pkg/edge/server.go b/pkg/edge/server.go index 1896d63..b673d3e 100644 --- a/pkg/edge/server.go +++ b/pkg/edge/server.go @@ -6,75 +6,169 @@ import ( "log" "net/http" "time" + + "github.com/sipeed/picoclaw/pkg/bus" ) // Server is the Edge API HTTP server. -// It exposes endpoints for health checks, sensor data ingestion, -// and command reception from the upstream Fleet Manager. type Server struct { config Config mux *http.ServeMux + bus *bus.MessageBus start time.Time } // NewServer creates a new Edge API Server. -func NewServer(cfg Config) *Server { - s := &Server{ - config: cfg, - mux: http.NewServeMux(), - start: time.Now(), - } - s.routes() - return s -} - -func (s *Server) routes() { +func NewServer(cfg Config, msgBus *bus.MessageBus) *Server { + s := &Server{config: cfg, mux: http.NewServeMux(), start: time.Now(), bus: msgBus} + s.mux.HandleFunc("GET /api/health", s.handleHealthz) + s.mux.HandleFunc("GET /api/status", s.handleStatus) + s.mux.HandleFunc("POST /api/command", s.handleCommand) + s.mux.HandleFunc("POST /api/message", s.handleMessage) s.mux.HandleFunc("GET /healthz", s.handleHealthz) s.mux.HandleFunc("GET /api/v1/status", s.handleStatus) s.mux.HandleFunc("POST /api/v1/command", s.handleCommand) + return s } +func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.mux.ServeHTTP(w, r) } + // Start begins listening on the configured port. func (s *Server) Start() error { addr := fmt.Sprintf(":%d", s.config.Port) log.Printf("[edge] API server listening on %s (node: %s)", addr, s.config.NodeID) - return http.ListenAndServe(addr, s.mux) + return http.ListenAndServe(addr, s) } +// --- Handlers --- + func (s *Server) handleHealthz(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]any{ - "status": "ok", - "agent": "picclaw", - "node_id": s.config.NodeID, - "uptime": time.Since(s.start).String(), + busStatus := "disconnected" + if s.bus != nil { + busStatus = "connected" + } + writeJSON(w, 200, map[string]any{ + "status": "ok", "agent": "picclaw", "node_id": s.config.NodeID, + "node_name": s.config.NodeName, "uptime": time.Since(s.start).String(), + "version": "0.1.0", "bus_connected": busStatus, }) } func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]any{ - "node_id": s.config.NodeID, - "node_name": s.config.NodeName, - "status": "running", - "uptime": time.Since(s.start).String(), - "cloud": s.config.Cloud.Endpoint, + busInfo := "disconnected" + if s.bus != nil { + busInfo = "connected" + } + writeJSON(w, 200, map[string]any{ + "node_id": s.config.NodeID, "node_name": s.config.NodeName, + "status": "running", "uptime": time.Since(s.start).String(), + "cloud": s.config.Cloud.Endpoint, "bus_connected": busInfo, }) } func (s *Server) handleCommand(w http.ResponseWriter, r *http.Request) { - var cmd struct { - Type string `json:"type"` - Payload map[string]any `json:"payload"` + var req struct { + CommandID string `json:"command_id"` + Type string `json:"type"` + Payload map[string]any `json:"payload"` + SenderID string `json:"sender_id"` + SessionKey string `json:"session_key"` + Metadata map[string]string `json:"metadata,omitempty"` } - if err := json.NewDecoder(r.Body).Decode(&cmd); err != nil { - http.Error(w, "invalid JSON", http.StatusBadRequest) + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, 400, map[string]any{"error": "invalid JSON: " + err.Error(), "code": 400}) return } - log.Printf("[edge] received command: type=%s", cmd.Type) + if req.Type == "" { + writeJSON(w, 400, map[string]any{"error": "command type is required", "code": 400}) + return + } + if req.CommandID == "" { + req.CommandID = fmt.Sprintf("cmd-%d", time.Now().UnixNano()) + } + if req.SenderID == "" { + req.SenderID = "fleet" + } + if req.SessionKey == "" { + req.SessionKey = "edge:cmd:" + req.CommandID + } + if req.Metadata == nil { + req.Metadata = make(map[string]string) + } + req.Metadata["kind"] = "command" + req.Metadata["command_id"] = req.CommandID + req.Metadata["command_type"] = req.Type + req.Metadata["edge_node_id"] = s.config.NodeID + if req.Payload != nil { + if b, err := json.Marshal(req.Payload); err == nil { + req.Metadata["payload"] = string(b) + } + } + if s.bus != nil { + s.bus.PublishInbound(bus.InboundMessage{ + Channel: "edge-api", SenderID: req.SenderID, ChatID: req.CommandID, + Content: req.Type, SessionKey: req.SessionKey, Metadata: req.Metadata, + }) + } + log.Printf("[edge] accepted command: type=%s id=%s node=%s", req.Type, req.CommandID, s.config.NodeID) + writeJSON(w, 202, map[string]any{"status": "accepted", "command_id": req.CommandID, "command": req.Type, "queued": s.bus != nil}) +} + +func (s *Server) handleMessage(w http.ResponseWriter, r *http.Request) { + var req struct { + MessageID string `json:"message_id"` + Channel string `json:"channel"` + SenderID string `json:"sender_id"` + ChatID string `json:"chat_id"` + Content string `json:"content"` + Media []string `json:"media,omitempty"` + SessionKey string `json:"session_key"` + Metadata map[string]string `json:"metadata,omitempty"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, 400, map[string]any{"error": "invalid JSON: " + err.Error(), "code": 400}) + return + } + if req.Content == "" { + writeJSON(w, 400, map[string]any{"error": "message content is required", "code": 400}) + return + } + if req.MessageID == "" { + req.MessageID = fmt.Sprintf("msg-%d", time.Now().UnixNano()) + } + if req.Channel == "" { + req.Channel = "edge-api" + } + if req.SenderID == "" { + req.SenderID = "fleet" + } + if req.ChatID == "" { + req.ChatID = req.MessageID + } + if req.SessionKey == "" { + req.SessionKey = "edge:msg:" + req.ChatID + } + if req.Metadata == nil { + req.Metadata = make(map[string]string) + } + req.Metadata["kind"] = "message" + req.Metadata["message_id"] = req.MessageID + req.Metadata["edge_node_id"] = s.config.NodeID + if s.bus != nil { + s.bus.PublishInbound(bus.InboundMessage{ + Channel: req.Channel, SenderID: req.SenderID, ChatID: req.ChatID, + Content: req.Content, Media: req.Media, SessionKey: req.SessionKey, + Metadata: req.Metadata, + }) + } + log.Printf("[edge] accepted message: id=%s sender=%s node=%s", req.MessageID, req.SenderID, s.config.NodeID) + writeJSON(w, 202, map[string]any{"status": "accepted", "message_id": req.MessageID, "queued": s.bus != nil}) +} + +// --- Helpers --- + +func writeJSON(w http.ResponseWriter, status int, payload any) { w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]any{ - "status": "accepted", - "command": cmd.Type, - }) -} \ No newline at end of file + w.WriteHeader(status) + json.NewEncoder(w).Encode(payload) +} diff --git a/pkg/edge/server_test.go b/pkg/edge/server_test.go new file mode 100644 index 0000000..76fe9f3 --- /dev/null +++ b/pkg/edge/server_test.go @@ -0,0 +1,171 @@ +package edge + +import ( + "bytes" + "context" + "encoding/json" + "net/http/httptest" + "testing" + "time" + + "github.com/sipeed/picoclaw/pkg/bus" +) + +func testConfig() Config { + return Config{ + Port: 9090, + NodeID: "edge-01", + NodeName: "pond-guardian", + Cloud: CloudConfig{Endpoint: "http://fleet.example.test"}, + } +} + +func consumeInbound(t *testing.T, mb *bus.MessageBus) bus.InboundMessage { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + msg, ok := mb.ConsumeInbound(ctx) + if !ok { + t.Fatal("expected inbound message on bus") + } + return msg +} + +func TestHealthAndStatus(t *testing.T) { + s := NewServer(testConfig(), bus.NewMessageBus()) + for _, tc := range []struct { + name string + path string + wantStatus int + check func(t *testing.T, body map[string]any) + }{ + {"/api/health", "/api/health", 200, func(t *testing.T, b map[string]any) { + if b["status"] != "ok" || b["agent"] != "picclaw" || b["node_id"] != "edge-01" || b["bus_connected"] != "connected" { + t.Errorf("unexpected health body: %v", b) + } + }}, + {"/healthz legacy", "/healthz", 200, func(t *testing.T, b map[string]any) { + if b["status"] != "ok" { + t.Errorf("healthz status = %v", b["status"]) + } + }}, + {"/api/status", "/api/status", 200, func(t *testing.T, b map[string]any) { + if b["node_name"] != "pond-guardian" || b["status"] != "running" || b["bus_connected"] != "connected" { + t.Errorf("unexpected status body: %v", b) + } + }}, + {"/api/v1/status legacy", "/api/v1/status", 200, func(t *testing.T, b map[string]any) { + if b["node_id"] != "edge-01" { + t.Errorf("node_id = %v", b["node_id"]) + } + }}, + {"404", "/api/nonexistent", 404, nil}, + } { + t.Run(tc.name, func(t *testing.T) { + rec := httptest.NewRecorder() + s.ServeHTTP(rec, httptest.NewRequest("GET", tc.path, nil)) + if rec.Code != tc.wantStatus { + t.Fatalf("status %d, want %d", rec.Code, tc.wantStatus) + } + if tc.check != nil { + var body map[string]any + json.Unmarshal(rec.Body.Bytes(), &body) + tc.check(t, body) + } + }) + } +} + +func TestHealthWithoutBus(t *testing.T) { + rec := httptest.NewRecorder() + NewServer(testConfig(), nil).ServeHTTP(rec, httptest.NewRequest("GET", "/api/health", nil)) + var body map[string]any + json.Unmarshal(rec.Body.Bytes(), &body) + if body["bus_connected"] != "disconnected" { + t.Errorf("bus_connected = %v, want disconnected", body["bus_connected"]) + } +} + +func TestCommandEndpoint(t *testing.T) { + for _, tc := range []struct { + name string + path string + body string + wantStatus int + wantBus bool + check func(t *testing.T, msg bus.InboundMessage) + }{ + {"full command", "/api/command", `{"command_id":"c1","type":"reboot","payload":{"delay":5},"sender_id":"admin"}`, 202, true, + func(t *testing.T, m bus.InboundMessage) { + if m.Channel != "edge-api" || m.Content != "reboot" || m.SenderID != "admin" || m.ChatID != "c1" || m.Metadata["kind"] != "command" { + t.Errorf("unexpected msg: %+v", m) + } + }}, + {"minimal", "/api/command", `{"type":"ping"}`, 202, true, + func(t *testing.T, m bus.InboundMessage) { + if m.Content != "ping" || m.SenderID != "fleet" || m.Metadata["command_id"] == "" { + t.Errorf("unexpected msg: %+v", m) + } + }}, + {"missing type", "/api/command", `{"command_id":"x"}`, 400, false, nil}, + {"empty body", "/api/command", ``, 400, false, nil}, + {"legacy path", "/api/v1/command", `{"type":"legacy"}`, 202, true, nil}, + } { + t.Run(tc.name, func(t *testing.T) { + mb := bus.NewMessageBus() + s := NewServer(testConfig(), mb) + rec := httptest.NewRecorder() + req := httptest.NewRequest("POST", tc.path, bytes.NewReader([]byte(tc.body))) + req.Header.Set("Content-Type", "application/json") + s.ServeHTTP(rec, req) + if rec.Code != tc.wantStatus { + t.Fatalf("status %d, want %d: %s", rec.Code, tc.wantStatus, rec.Body.String()) + } + if tc.wantBus && tc.check != nil { + tc.check(t, consumeInbound(t, mb)) + } + }) + } +} + +func TestMessageEndpoint(t *testing.T) { + for _, tc := range []struct { + name string + body string + wantStatus int + wantBus bool + check func(t *testing.T, msg bus.InboundMessage) + }{ + {"full message", `{"message_id":"m1","channel":"edge-api","sender_id":"op","chat_id":"room","content":"hello","media":["img.jpg"],"metadata":{"prio":"high"}}`, 202, true, + func(t *testing.T, m bus.InboundMessage) { + if m.Channel != "edge-api" || m.Content != "hello" || m.SenderID != "op" || m.ChatID != "room" || len(m.Media) != 1 || m.Metadata["kind"] != "message" { + t.Errorf("unexpected msg: %+v", m) + } + }}, + {"minimal", `{"content":"hi"}`, 202, true, + func(t *testing.T, m bus.InboundMessage) { + if m.Content != "hi" || m.Channel != "edge-api" || m.SenderID != "fleet" { + t.Errorf("unexpected msg: %+v", m) + } + }}, + {"missing content", `{"sender_id":"op"}`, 400, false, nil}, + {"empty object", `{}`, 400, false, nil}, + {"empty body", ``, 400, false, nil}, + {"malformed JSON", `{invalid`, 400, false, nil}, + } { + t.Run(tc.name, func(t *testing.T) { + mb := bus.NewMessageBus() + s := NewServer(testConfig(), mb) + rec := httptest.NewRecorder() + req := httptest.NewRequest("POST", "/api/message", bytes.NewReader([]byte(tc.body))) + req.Header.Set("Content-Type", "application/json") + s.ServeHTTP(rec, req) + if rec.Code != tc.wantStatus { + t.Fatalf("status %d, want %d: %s", rec.Code, tc.wantStatus, rec.Body.String()) + } + if tc.wantBus && tc.check != nil { + tc.check(t, consumeInbound(t, mb)) + } + }) + } +} diff --git a/pkg/gene/selector.go b/pkg/gene/selector.go index 40e31ef..a41fe3c 100644 --- a/pkg/gene/selector.go +++ b/pkg/gene/selector.go @@ -81,9 +81,15 @@ func SelectGenes(genes []Gene, signals []string, preset StrategyPreset, maxResul } } - // Sort by score descending + // Sort by score descending, with deterministic tie-breakers 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 {