diff --git a/cmd/picoclaw/main.go b/cmd/picoclaw/main.go index 269f9b6..67d5b07 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 @@ -991,7 +991,7 @@ func cronHelp() { func cronListCmd(storePath string) { cs := cron.NewCronService(storePath, nil) - jobs := cs.ListJobs(true) // Show all jobs, including disabled + jobs := cs.ListJobs(true) // Show all jobs, including disabled if len(jobs) == 0 { fmt.Println("No scheduled jobs.") diff --git a/pkg/edge/server.go b/pkg/edge/server.go index 1896d63..1b6b816 100644 --- a/pkg/edge/server.go +++ b/pkg/edge/server.go @@ -6,6 +6,8 @@ import ( "log" "net/http" "time" + + "github.com/sipeed/picoclaw/pkg/bus" ) // Server is the Edge API HTTP server. @@ -15,14 +17,20 @@ type Server struct { config Config mux *http.ServeMux start time.Time + bus *bus.MessageBus } // NewServer creates a new Edge API Server. -func NewServer(cfg Config) *Server { +func NewServer(cfg Config, buses ...*bus.MessageBus) *Server { + var msgBus *bus.MessageBus + if len(buses) > 0 { + msgBus = buses[0] + } s := &Server{ config: cfg, mux: http.NewServeMux(), start: time.Now(), + bus: msgBus, } s.routes() return s @@ -30,8 +38,13 @@ func NewServer(cfg Config) *Server { func (s *Server) routes() { s.mux.HandleFunc("GET /healthz", s.handleHealthz) + s.mux.HandleFunc("GET /api/health", s.handleHealthz) + s.mux.HandleFunc("GET /api/status", s.handleStatus) s.mux.HandleFunc("GET /api/v1/status", s.handleStatus) + s.mux.HandleFunc("POST /api/command", s.handleCommand) s.mux.HandleFunc("POST /api/v1/command", s.handleCommand) + s.mux.HandleFunc("POST /api/message", s.handleMessage) + s.mux.HandleFunc("POST /api/v1/message", s.handleMessage) } // Start begins listening on the configured port. @@ -41,40 +54,190 @@ func (s *Server) Start() error { return http.ListenAndServe(addr, s.mux) } +func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { + s.mux.ServeHTTP(w, r) +} + 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(), + "status": "ok", + "agent": "picclaw", + "node_id": s.config.NodeID, + "node_name": s.config.NodeName, + "uptime": time.Since(s.start).String(), + "bus_connected": s.bus != nil, }) } 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, + "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": s.bus != nil, + "endpoints": []string{ + "POST /api/command", + "POST /api/message", + "GET /api/status", + "GET /api/health", + "GET /healthz", + }, }) } func (s *Server) handleCommand(w http.ResponseWriter, r *http.Request) { - var cmd struct { - Type string `json:"type"` - Payload map[string]any `json:"payload"` + var cmd edgeCommand + if err := decodeJSON(r, &cmd); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON") + return } - if err := json.NewDecoder(r.Body).Decode(&cmd); err != nil { - http.Error(w, "invalid JSON", http.StatusBadRequest) + if cmd.Type == "" { + writeError(w, http.StatusBadRequest, "command type is required") return } - log.Printf("[edge] received command: type=%s", cmd.Type) - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]any{ - "status": "accepted", - "command": cmd.Type, + + log.Printf("[edge] received command: type=%s id=%s", cmd.Type, cmd.CommandID) + if s.bus != nil { + s.bus.PublishInbound(cmd.toInbound(s.config.NodeID)) + } + + writeJSON(w, http.StatusAccepted, map[string]any{ + "status": "accepted", + "command_id": cmd.CommandID, + "command": cmd.Type, + "queued": s.bus != nil, }) -} \ No newline at end of file +} + +func (s *Server) handleMessage(w http.ResponseWriter, r *http.Request) { + var msg edgeMessage + if err := decodeJSON(r, &msg); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON") + return + } + if msg.Content == "" { + writeError(w, http.StatusBadRequest, "message content is required") + return + } + + log.Printf("[edge] received message: id=%s sender=%s", msg.MessageID, msg.SenderID) + if s.bus != nil { + s.bus.PublishInbound(msg.toInbound(s.config.NodeID)) + } + + writeJSON(w, http.StatusAccepted, map[string]any{ + "status": "accepted", + "message_id": msg.MessageID, + "queued": s.bus != nil, + }) +} + +type edgeCommand 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"` +} + +type edgeMessage struct { + MessageID string `json:"message_id"` + SenderID string `json:"sender_id"` + ChatID string `json:"chat_id"` + Content string `json:"content"` + Media []string `json:"media"` + SessionKey string `json:"session_key"` + Metadata map[string]string `json:"metadata"` +} + +func (cmd edgeCommand) toInbound(nodeID string) bus.InboundMessage { + metadata := cloneMetadata(cmd.Metadata) + metadata["edge_node_id"] = nodeID + metadata["kind"] = "command" + metadata["command_id"] = cmd.CommandID + metadata["payload"] = jsonString(cmd.Payload) + + senderID := cmd.SenderID + if senderID == "" { + senderID = "edge-cloud" + } + + return bus.InboundMessage{ + Channel: "edge-api", + SenderID: senderID, + ChatID: firstNonEmpty(cmd.CommandID, nodeID), + Content: cmd.Type, + SessionKey: cmd.SessionKey, + Metadata: metadata, + } +} + +func (msg edgeMessage) toInbound(nodeID string) bus.InboundMessage { + metadata := cloneMetadata(msg.Metadata) + metadata["edge_node_id"] = nodeID + metadata["kind"] = "message" + metadata["message_id"] = msg.MessageID + + senderID := msg.SenderID + if senderID == "" { + senderID = "edge-cloud" + } + + return bus.InboundMessage{ + Channel: "edge-api", + SenderID: senderID, + ChatID: firstNonEmpty(msg.ChatID, msg.MessageID, nodeID), + Content: msg.Content, + Media: msg.Media, + SessionKey: msg.SessionKey, + Metadata: metadata, + } +} + +func decodeJSON(r *http.Request, v any) error { + decoder := json.NewDecoder(r.Body) + return decoder.Decode(v) +} + +func writeJSON(w http.ResponseWriter, status int, payload any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(payload) +} + +func writeError(w http.ResponseWriter, status int, message string) { + writeJSON(w, status, map[string]string{"error": message}) +} + +func cloneMetadata(metadata map[string]string) map[string]string { + out := make(map[string]string, len(metadata)+4) + for key, value := range metadata { + out[key] = value + } + return out +} + +func jsonString(value any) string { + if value == nil { + return "{}" + } + encoded, err := json.Marshal(value) + if err != nil { + return "{}" + } + return string(encoded) +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if value != "" { + return value + } + } + return "" +} diff --git a/pkg/edge/server_test.go b/pkg/edge/server_test.go new file mode 100644 index 0000000..3e4042f --- /dev/null +++ b/pkg/edge/server_test.go @@ -0,0 +1,132 @@ +package edge + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/sipeed/picoclaw/pkg/bus" +) + +func testServer(msgBus *bus.MessageBus) *Server { + return NewServer(Config{ + Port: 9090, + NodeID: "edge-01", + NodeName: "PicClaw Lab", + Cloud: CloudConfig{ + Endpoint: "https://fleet.example.test", + }, + }, msgBus) +} + +func TestHealthAndStatusEndpoints(t *testing.T) { + msgBus := bus.NewMessageBus() + server := testServer(msgBus) + + for _, path := range []string{"/healthz", "/api/health"} { + req := httptest.NewRequest(http.MethodGet, path, nil) + rec := httptest.NewRecorder() + server.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("%s returned %d", path, rec.Code) + } + var body map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("decode health response: %v", err) + } + if body["status"] != "ok" || body["node_id"] != "edge-01" || body["bus_connected"] != true { + t.Fatalf("unexpected health response for %s: %#v", path, body) + } + } + + req := httptest.NewRequest(http.MethodGet, "/api/status", nil) + rec := httptest.NewRecorder() + server.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status returned %d", rec.Code) + } + + var status map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &status); err != nil { + t.Fatalf("decode status response: %v", err) + } + if status["cloud"] != "https://fleet.example.test" || status["status"] != "running" { + t.Fatalf("unexpected status response: %#v", status) + } +} + +func TestCommandEndpointPublishesToMessageBus(t *testing.T) { + msgBus := bus.NewMessageBus() + server := testServer(msgBus) + + body := `{"command_id":"cmd-1","type":"restart_sensor","payload":{"sensor":"temp"},"sender_id":"fleet","session_key":"session-1"}` + req := httptest.NewRequest(http.MethodPost, "/api/command", strings.NewReader(body)) + rec := httptest.NewRecorder() + server.ServeHTTP(rec, req) + if rec.Code != http.StatusAccepted { + t.Fatalf("command returned %d: %s", rec.Code, rec.Body.String()) + } + + msg := consumeInbound(t, msgBus) + if msg.Channel != "edge-api" || msg.Content != "restart_sensor" || msg.ChatID != "cmd-1" { + t.Fatalf("unexpected inbound command: %#v", msg) + } + if msg.Metadata["kind"] != "command" || msg.Metadata["payload"] != `{"sensor":"temp"}` { + t.Fatalf("unexpected command metadata: %#v", msg.Metadata) + } +} + +func TestMessageEndpointPublishesToMessageBus(t *testing.T) { + msgBus := bus.NewMessageBus() + server := testServer(msgBus) + + body := `{"message_id":"msg-1","sender_id":"fleet","chat_id":"ops","content":"check pump","media":["image.jpg"],"metadata":{"priority":"high"}}` + req := httptest.NewRequest(http.MethodPost, "/api/message", strings.NewReader(body)) + rec := httptest.NewRecorder() + server.ServeHTTP(rec, req) + if rec.Code != http.StatusAccepted { + t.Fatalf("message returned %d: %s", rec.Code, rec.Body.String()) + } + + msg := consumeInbound(t, msgBus) + if msg.Channel != "edge-api" || msg.Content != "check pump" || msg.ChatID != "ops" { + t.Fatalf("unexpected inbound message: %#v", msg) + } + if len(msg.Media) != 1 || msg.Media[0] != "image.jpg" { + t.Fatalf("unexpected media: %#v", msg.Media) + } + if msg.Metadata["priority"] != "high" || msg.Metadata["kind"] != "message" { + t.Fatalf("unexpected message metadata: %#v", msg.Metadata) + } +} + +func TestInvalidJSONReturnsBadRequest(t *testing.T) { + server := testServer(bus.NewMessageBus()) + + req := httptest.NewRequest(http.MethodPost, "/api/command", strings.NewReader("{")) + rec := httptest.NewRecorder() + server.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", rec.Code) + } + if !strings.Contains(rec.Body.String(), "invalid JSON") { + t.Fatalf("unexpected error response: %s", rec.Body.String()) + } +} + +func consumeInbound(t *testing.T, msgBus *bus.MessageBus) bus.InboundMessage { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + + msg, ok := msgBus.ConsumeInbound(ctx) + if !ok { + t.Fatal("expected inbound message") + } + return msg +} 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 {