Initial commit: Hexis capability registry + execution service baseline
Go daemon (hexisd/hexisctl) implementing capability registry, guarded execution (confirmations, blessed-entity checks), systemd/workspace-mcp providers per ECOSYSTEM-SPEC.md. Snapshotting existing working state before further development.
This commit is contained in:
@@ -0,0 +1,259 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kami/hexis/internal/domain"
|
||||
"github.com/kami/hexis/internal/execution"
|
||||
"github.com/kami/hexis/internal/storage"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
store storage.Interface
|
||||
engine *execution.Engine
|
||||
}
|
||||
|
||||
func NewHandler(store storage.Interface, engine *execution.Engine) *Handler {
|
||||
return &Handler{store: store, engine: engine}
|
||||
}
|
||||
|
||||
func (h *Handler) Register(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/health", h.health)
|
||||
mux.HandleFunc("/ready", h.ready)
|
||||
mux.HandleFunc("/api/v1/capabilities", h.handleCapabilities)
|
||||
mux.HandleFunc("/api/v1/capabilities/", h.handleCapabilityByID)
|
||||
mux.HandleFunc("/api/v1/execute", h.handleExecute)
|
||||
mux.HandleFunc("/api/v1/executions/", h.handleExecutionByID)
|
||||
mux.HandleFunc("/api/v1/changes", h.handleChanges)
|
||||
}
|
||||
|
||||
func (h *Handler) health(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (h *Handler) ready(w http.ResponseWriter, r *http.Request) {
|
||||
_, err := h.store.LatestSequence()
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"status": "not_ready"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ready"})
|
||||
}
|
||||
|
||||
func (h *Handler) handleCapabilities(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
h.listCapabilities(w, r)
|
||||
case http.MethodPost:
|
||||
h.createCapability(w, r)
|
||||
default:
|
||||
writeJSON(w, http.StatusMethodNotAllowed, errorResponse("method not allowed"))
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) listCapabilities(w http.ResponseWriter, r *http.Request) {
|
||||
entityID := r.URL.Query().Get("entity_id")
|
||||
caps, err := h.store.ListCapabilities(entityID)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, errorResponse(err.Error()))
|
||||
return
|
||||
}
|
||||
if caps == nil {
|
||||
caps = []*domain.Capability{}
|
||||
}
|
||||
|
||||
var apiCaps []map[string]any
|
||||
for _, c := range caps {
|
||||
apiCaps = append(apiCaps, map[string]any{
|
||||
"capability_id": c.ID,
|
||||
"id": c.ID,
|
||||
"name": c.Name,
|
||||
"description": c.Description,
|
||||
"target_types": c.TargetTypes,
|
||||
"target_entity_id": c.TargetEntityID,
|
||||
"provider": c.Provider,
|
||||
"operation": c.Operation,
|
||||
"risk": c.Risk,
|
||||
"read_only": c.ReadOnly,
|
||||
"expected_side_effects": c.ExpectedSideEffects,
|
||||
})
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, apiCaps)
|
||||
}
|
||||
|
||||
func (h *Handler) createCapability(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
TargetTypes []string `json:"target_types"`
|
||||
TargetEntityID string `json:"target_entity_id,omitempty"`
|
||||
Provider string `json:"provider"`
|
||||
Operation string `json:"operation"`
|
||||
Risk string `json:"risk,omitempty"`
|
||||
ReadOnly bool `json:"read_only"`
|
||||
ExpectedSideEffects string `json:"expected_side_effects,omitempty"`
|
||||
Attributes map[string]any `json:"attributes,omitempty"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, errorResponse("invalid JSON"))
|
||||
return
|
||||
}
|
||||
if req.Name == "" || req.Provider == "" || req.Operation == "" {
|
||||
writeJSON(w, http.StatusBadRequest, errorResponse("name, provider, and operation are required"))
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
cap := &domain.Capability{
|
||||
ID: domain.NewCapabilityID(),
|
||||
Name: req.Name,
|
||||
Description: req.Description,
|
||||
TargetTypes: req.TargetTypes,
|
||||
TargetEntityID: req.TargetEntityID,
|
||||
Provider: req.Provider,
|
||||
Operation: req.Operation,
|
||||
Risk: req.Risk,
|
||||
ReadOnly: req.ReadOnly,
|
||||
ExpectedSideEffects: req.ExpectedSideEffects,
|
||||
Attributes: req.Attributes,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
Version: 1,
|
||||
}
|
||||
if cap.TargetTypes == nil {
|
||||
cap.TargetTypes = []string{}
|
||||
}
|
||||
if cap.Attributes == nil {
|
||||
cap.Attributes = map[string]any{}
|
||||
}
|
||||
|
||||
if err := h.store.CreateCapability(cap); err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, errorResponse(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
h.store.AppendEvent(&domain.Event{
|
||||
ID: domain.NewEventID(),
|
||||
Type: domain.EventCapabilityRegistered,
|
||||
Timestamp: now,
|
||||
Payload: map[string]any{"capability_id": cap.ID, "name": cap.Name},
|
||||
})
|
||||
|
||||
writeJSON(w, http.StatusCreated, cap)
|
||||
}
|
||||
|
||||
func (h *Handler) handleCapabilityByID(w http.ResponseWriter, r *http.Request) {
|
||||
id := strings.TrimPrefix(r.URL.Path, "/api/v1/capabilities/")
|
||||
if id == "" {
|
||||
writeJSON(w, http.StatusBadRequest, errorResponse("id required"))
|
||||
return
|
||||
}
|
||||
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
h.getCapability(w, r, id)
|
||||
case http.MethodDelete:
|
||||
h.deleteCapability(w, r, id)
|
||||
default:
|
||||
writeJSON(w, http.StatusMethodNotAllowed, errorResponse("method not allowed"))
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) getCapability(w http.ResponseWriter, r *http.Request, id string) {
|
||||
cap, err := h.store.GetCapability(id)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusNotFound, errorResponse(err.Error()))
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, cap)
|
||||
}
|
||||
|
||||
func (h *Handler) deleteCapability(w http.ResponseWriter, r *http.Request, id string) {
|
||||
if err := h.store.DeleteCapability(id); err != nil {
|
||||
writeJSON(w, http.StatusNotFound, errorResponse(err.Error()))
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
|
||||
}
|
||||
|
||||
func (h *Handler) handleExecute(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeJSON(w, http.StatusMethodNotAllowed, errorResponse("method not allowed"))
|
||||
return
|
||||
}
|
||||
|
||||
var req domain.ExecuteRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, errorResponse("invalid JSON"))
|
||||
return
|
||||
}
|
||||
if req.CapabilityID == "" || req.TargetEntityID == "" {
|
||||
writeJSON(w, http.StatusBadRequest, errorResponse("capability_id and target_entity_id are required"))
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.engine.Execute(&req)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, errorResponse(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, result.Execution)
|
||||
}
|
||||
|
||||
func (h *Handler) handleExecutionByID(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeJSON(w, http.StatusMethodNotAllowed, errorResponse("method not allowed"))
|
||||
return
|
||||
}
|
||||
|
||||
id := strings.TrimPrefix(r.URL.Path, "/api/v1/executions/")
|
||||
if id == "" {
|
||||
writeJSON(w, http.StatusBadRequest, errorResponse("id required"))
|
||||
return
|
||||
}
|
||||
|
||||
exec, err := h.store.GetExecution(id)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusNotFound, errorResponse(err.Error()))
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, exec)
|
||||
}
|
||||
|
||||
func (h *Handler) handleChanges(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeJSON(w, http.StatusMethodNotAllowed, errorResponse("method not allowed"))
|
||||
return
|
||||
}
|
||||
|
||||
seqStr := r.URL.Query().Get("since")
|
||||
var since int64
|
||||
if seqStr != "" {
|
||||
since = 0
|
||||
}
|
||||
|
||||
events, err := h.store.EventsAfter(since, 100)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, errorResponse(err.Error()))
|
||||
return
|
||||
}
|
||||
if events == nil {
|
||||
events = []*domain.Event{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, events)
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func errorResponse(msg string) map[string]string {
|
||||
return map[string]string{"error": msg}
|
||||
}
|
||||
Reference in New Issue
Block a user