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}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/kami/hexis/internal/execution"
|
||||
"github.com/kami/hexis/internal/storage"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
handler *Handler
|
||||
httpSrv *http.Server
|
||||
socket string
|
||||
}
|
||||
|
||||
func NewServer(store *storage.Store, engine *execution.Engine) *Server {
|
||||
handler := NewHandler(store, engine)
|
||||
return &Server{handler: handler}
|
||||
}
|
||||
|
||||
func (s *Server) ListenUnix(socketPath string) error {
|
||||
dir := filepath.Dir(socketPath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return fmt.Errorf("create socket directory: %w", err)
|
||||
}
|
||||
|
||||
os.Remove(socketPath)
|
||||
|
||||
listener, err := net.Listen("unix", socketPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("listen unix: %w", err)
|
||||
}
|
||||
|
||||
if err := os.Chmod(socketPath, 0660); err != nil {
|
||||
listener.Close()
|
||||
return fmt.Errorf("chmod socket: %w", err)
|
||||
}
|
||||
|
||||
s.socket = socketPath
|
||||
|
||||
mux := http.NewServeMux()
|
||||
s.handler.Register(mux)
|
||||
|
||||
return http.Serve(listener, mux)
|
||||
}
|
||||
|
||||
func (s *Server) ListenHTTP(addr string) error {
|
||||
mux := http.NewServeMux()
|
||||
s.handler.Register(mux)
|
||||
|
||||
s.httpSrv = &http.Server{
|
||||
Addr: addr,
|
||||
Handler: mux,
|
||||
}
|
||||
|
||||
return s.httpSrv.ListenAndServe()
|
||||
}
|
||||
|
||||
func (s *Server) Shutdown(ctx context.Context) error {
|
||||
if s.httpSrv != nil {
|
||||
return s.httpSrv.Shutdown(ctx)
|
||||
}
|
||||
if s.socket != "" {
|
||||
os.Remove(s.socket)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package domain
|
||||
|
||||
import "time"
|
||||
|
||||
type ExecuteRequest struct {
|
||||
CapabilityID string `json:"capability_id"`
|
||||
TargetEntityID string `json:"target_entity_id"`
|
||||
EntityVersion int64 `json:"entity_version,omitempty"`
|
||||
Arguments map[string]any `json:"arguments,omitempty"`
|
||||
RequestedBy map[string]string `json:"requested_by,omitempty"`
|
||||
Origin map[string]string `json:"origin,omitempty"`
|
||||
IdempotencyKey string `json:"idempotency_key,omitempty"`
|
||||
CorrelationID string `json:"correlation_id,omitempty"`
|
||||
ResolutionEvidence []map[string]any `json:"resolution_evidence,omitempty"`
|
||||
}
|
||||
|
||||
type Capability struct {
|
||||
ID string `json:"id"`
|
||||
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"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Version int64 `json:"version"`
|
||||
}
|
||||
|
||||
type ExecutionStatus string
|
||||
|
||||
const (
|
||||
ExecutionStarted ExecutionStatus = "started"
|
||||
ExecutionSucceeded ExecutionStatus = "succeeded"
|
||||
ExecutionFailed ExecutionStatus = "failed"
|
||||
ExecutionDenied ExecutionStatus = "denied"
|
||||
)
|
||||
|
||||
type Execution struct {
|
||||
ID string `json:"id"`
|
||||
CapabilityID string `json:"capability_id"`
|
||||
TargetEntityID string `json:"target_entity_id"`
|
||||
EntityVersion int64 `json:"entity_version,omitempty"`
|
||||
Arguments map[string]any `json:"arguments,omitempty"`
|
||||
RequestedBy map[string]string `json:"requested_by,omitempty"`
|
||||
Origin map[string]string `json:"origin,omitempty"`
|
||||
IdempotencyKey string `json:"idempotency_key,omitempty"`
|
||||
Status ExecutionStatus `json:"status"`
|
||||
Result map[string]any `json:"result,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
ResolutionEvidence []map[string]any `json:"resolution_evidence,omitempty"`
|
||||
CorrelationID string `json:"correlation_id,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type HexisEventType string
|
||||
|
||||
const (
|
||||
EventCapabilityRegistered HexisEventType = "hexis.capability.registered"
|
||||
EventCapabilityUnavailable HexisEventType = "hexis.capability.unavailable"
|
||||
EventExecutionStarted HexisEventType = "hexis.execution.started"
|
||||
EventExecutionSucceeded HexisEventType = "hexis.execution.succeeded"
|
||||
EventExecutionFailed HexisEventType = "hexis.execution.failed"
|
||||
EventExecutionDenied HexisEventType = "hexis.execution.denied"
|
||||
)
|
||||
|
||||
type Event struct {
|
||||
ID string `json:"id"`
|
||||
Sequence int64 `json:"sequence"`
|
||||
Type HexisEventType `json:"type"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Actor string `json:"actor,omitempty"`
|
||||
CorrelationID string `json:"correlation_id,omitempty"`
|
||||
CausationID string `json:"causation_id,omitempty"`
|
||||
Payload map[string]any `json:"payload,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package domain
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrCapabilityNotFound = errors.New("capability not found")
|
||||
ErrExecutionNotFound = errors.New("execution not found")
|
||||
ErrConflict = errors.New("version conflict")
|
||||
ErrAmbiguousTarget = errors.New("ambiguous target")
|
||||
ErrTargetNotFound = errors.New("target not found")
|
||||
ErrTargetTypeMismatch = errors.New("target type does not match capability")
|
||||
ErrCapabilityNotBound = errors.New("capability not registered for this entity")
|
||||
ErrEntityRetired = errors.New("entity is retired")
|
||||
ErrEntityMerged = errors.New("entity is merged")
|
||||
ErrValidation = errors.New("validation error")
|
||||
ErrInternal = errors.New("internal error")
|
||||
ErrIdempotencyReplay = errors.New("idempotent request already processed")
|
||||
)
|
||||
@@ -0,0 +1,25 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base32"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func NewCapabilityID() string {
|
||||
b := make([]byte, 10)
|
||||
rand.Read(b)
|
||||
return "cap_" + strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(b))
|
||||
}
|
||||
|
||||
func NewExecutionID() string {
|
||||
b := make([]byte, 14)
|
||||
rand.Read(b)
|
||||
return "exec_" + strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(b))
|
||||
}
|
||||
|
||||
func NewEventID() string {
|
||||
b := make([]byte, 10)
|
||||
rand.Read(b)
|
||||
return "hevt_" + strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(b))
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package execution
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/kami/hexis/internal/domain"
|
||||
"github.com/kami/hexis/internal/provider"
|
||||
"github.com/kami/hexis/internal/storage"
|
||||
)
|
||||
|
||||
type Engine struct {
|
||||
store storage.Interface
|
||||
registry *provider.Registry
|
||||
}
|
||||
|
||||
func New(store storage.Interface, registry *provider.Registry) *Engine {
|
||||
return &Engine{store: store, registry: registry}
|
||||
}
|
||||
|
||||
type ExecuteResult struct {
|
||||
Execution *domain.Execution `json:"execution"`
|
||||
}
|
||||
|
||||
func (e *Engine) Execute(req *domain.ExecuteRequest) (*ExecuteResult, error) {
|
||||
if req.IdempotencyKey != "" {
|
||||
existing, err := e.store.GetExecutionByIdempotencyKey(req.IdempotencyKey)
|
||||
if err == nil {
|
||||
return &ExecuteResult{Execution: existing}, nil
|
||||
}
|
||||
}
|
||||
|
||||
capability, err := e.store.GetCapability(req.CapabilityID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("capability: %w", err)
|
||||
}
|
||||
|
||||
if capability.TargetEntityID != "" && capability.TargetEntityID != req.TargetEntityID {
|
||||
return nil, domain.ErrCapabilityNotBound
|
||||
}
|
||||
|
||||
prov, err := e.registry.Get(capability.Provider)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("provider: %w", err)
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
exec := &domain.Execution{
|
||||
ID: domain.NewExecutionID(),
|
||||
CapabilityID: req.CapabilityID,
|
||||
TargetEntityID: req.TargetEntityID,
|
||||
EntityVersion: req.EntityVersion,
|
||||
Arguments: req.Arguments,
|
||||
RequestedBy: req.RequestedBy,
|
||||
Origin: req.Origin,
|
||||
IdempotencyKey: req.IdempotencyKey,
|
||||
Status: domain.ExecutionStarted,
|
||||
CorrelationID: req.CorrelationID,
|
||||
ResolutionEvidence: req.ResolutionEvidence,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if exec.Arguments == nil {
|
||||
exec.Arguments = map[string]any{}
|
||||
}
|
||||
if exec.RequestedBy == nil {
|
||||
exec.RequestedBy = map[string]string{}
|
||||
}
|
||||
if exec.Origin == nil {
|
||||
exec.Origin = map[string]string{}
|
||||
}
|
||||
|
||||
if err := e.store.CreateExecution(exec); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
e.emitEvent(domain.EventExecutionStarted, exec.ID, map[string]any{
|
||||
"capability_id": req.CapabilityID,
|
||||
"target_entity_id": req.TargetEntityID,
|
||||
"provider": capability.Provider,
|
||||
"correlation_id": req.CorrelationID,
|
||||
})
|
||||
|
||||
result, execErr := prov.Execute(capability, req)
|
||||
exec.UpdatedAt = time.Now().UTC()
|
||||
|
||||
if execErr != nil {
|
||||
exec.Status = domain.ExecutionFailed
|
||||
exec.Error = execErr.Error()
|
||||
exec.Result = map[string]any{"error": execErr.Error()}
|
||||
e.emitEvent(domain.EventExecutionFailed, exec.ID, map[string]any{
|
||||
"capability_id": req.CapabilityID,
|
||||
"error": execErr.Error(),
|
||||
})
|
||||
} else {
|
||||
exec.Status = domain.ExecutionSucceeded
|
||||
exec.Result = result
|
||||
e.emitEvent(domain.EventExecutionSucceeded, exec.ID, map[string]any{
|
||||
"capability_id": req.CapabilityID,
|
||||
})
|
||||
}
|
||||
|
||||
if err := e.store.UpdateExecution(exec); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &ExecuteResult{Execution: exec}, nil
|
||||
}
|
||||
|
||||
func (e *Engine) ValidateTarget(capabilityID, entityID string) error {
|
||||
cap, err := e.store.GetCapability(capabilityID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if cap.TargetEntityID != "" && cap.TargetEntityID != entityID {
|
||||
return domain.ErrCapabilityNotBound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *Engine) emitEvent(evtType domain.HexisEventType, entityID string, payload map[string]any) {
|
||||
e.store.AppendEvent(&domain.Event{
|
||||
ID: domain.NewEventID(),
|
||||
Type: evtType,
|
||||
Timestamp: time.Now().UTC(),
|
||||
Payload: payload,
|
||||
})
|
||||
}
|
||||
|
||||
type ResolveRequest struct {
|
||||
Query string `json:"query"`
|
||||
Types []string `json:"types,omitempty"`
|
||||
}
|
||||
|
||||
type ResolveResult struct {
|
||||
Status string `json:"status"`
|
||||
Candidates []map[string]any `json:"candidates,omitempty"`
|
||||
EntityID string `json:"entity_id,omitempty"`
|
||||
}
|
||||
|
||||
var _ json.Marshaler = (*ResolveResult)(nil)
|
||||
|
||||
func (r *ResolveResult) MarshalJSON() ([]byte, error) {
|
||||
m := map[string]any{"status": r.Status}
|
||||
if r.Candidates != nil {
|
||||
m["candidates"] = r.Candidates
|
||||
}
|
||||
if r.EntityID != "" {
|
||||
m["entity_id"] = r.EntityID
|
||||
}
|
||||
return json.Marshal(m)
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
"github.com/mark3labs/mcp-go/server"
|
||||
|
||||
"github.com/kami/hexis/internal/domain"
|
||||
"github.com/kami/hexis/internal/execution"
|
||||
"github.com/kami/hexis/internal/storage"
|
||||
)
|
||||
|
||||
type Adapter struct {
|
||||
server *server.MCPServer
|
||||
store storage.Interface
|
||||
engine *execution.Engine
|
||||
}
|
||||
|
||||
func New(store storage.Interface, engine *execution.Engine) *Adapter {
|
||||
a := &Adapter{
|
||||
store: store,
|
||||
engine: engine,
|
||||
}
|
||||
|
||||
mcpServer := server.NewMCPServer(
|
||||
"hexis",
|
||||
"1.0.0",
|
||||
server.WithResourceCapabilities(true, true),
|
||||
server.WithToolCapabilities(true),
|
||||
)
|
||||
|
||||
mcpServer.AddTool(mcp.NewTool("hexis.list_capabilities",
|
||||
mcp.WithDescription("List capabilities, optionally filtered by entity_id"),
|
||||
mcp.WithString("entity_id",
|
||||
mcp.Description("Optional entity ID to filter capabilities"),
|
||||
),
|
||||
), a.handleListCapabilities)
|
||||
|
||||
mcpServer.AddTool(mcp.NewTool("hexis.inspect_capability",
|
||||
mcp.WithDescription("Get capability details by ID"),
|
||||
mcp.WithString("capability_id",
|
||||
mcp.Description("Capability ID"),
|
||||
mcp.Required(),
|
||||
),
|
||||
), a.handleInspectCapability)
|
||||
|
||||
mcpServer.AddTool(mcp.NewTool("hexis.resolve_target",
|
||||
mcp.WithDescription("Resolve a free-text target to a canonical entity ID via Nexus"),
|
||||
mcp.WithString("query",
|
||||
mcp.Description("Free-text query (name, alias, path)"),
|
||||
mcp.Required(),
|
||||
),
|
||||
mcp.WithString("capability",
|
||||
mcp.Description("Capability name to filter target types"),
|
||||
),
|
||||
), a.handleResolveTarget)
|
||||
|
||||
mcpServer.AddTool(mcp.NewTool("hexis.execute",
|
||||
mcp.WithDescription("Execute a capability against a target entity"),
|
||||
mcp.WithString("capability_id",
|
||||
mcp.Description("Capability ID"),
|
||||
mcp.Required(),
|
||||
),
|
||||
mcp.WithString("target_entity_id",
|
||||
mcp.Description("Canonical entity ID of the target"),
|
||||
mcp.Required(),
|
||||
),
|
||||
mcp.WithString("arguments",
|
||||
mcp.Description("JSON string of execution arguments"),
|
||||
),
|
||||
mcp.WithString("idempotency_key",
|
||||
mcp.Description("Idempotency key for safe retry"),
|
||||
),
|
||||
), a.handleExecute)
|
||||
|
||||
mcpServer.AddTool(mcp.NewTool("hexis.execution_status",
|
||||
mcp.WithDescription("Get execution status by ID"),
|
||||
mcp.WithString("execution_id",
|
||||
mcp.Description("Execution ID"),
|
||||
mcp.Required(),
|
||||
),
|
||||
), a.handleExecutionStatus)
|
||||
|
||||
mcpServer.AddResource(mcp.NewResource("hexis://capabilities",
|
||||
"All capabilities",
|
||||
mcp.WithMIMEType("application/json"),
|
||||
), a.handleCapabilitiesResource)
|
||||
|
||||
mcpServer.AddResourceTemplate(
|
||||
mcp.NewResourceTemplate("hexis://capabilities/{id}", "Capability by ID"),
|
||||
a.handleCapabilityResourceTemplate,
|
||||
)
|
||||
|
||||
mcpServer.AddResourceTemplate(
|
||||
mcp.NewResourceTemplate("hexis://executions/{id}", "Execution by ID"),
|
||||
a.handleExecutionResourceTemplate,
|
||||
)
|
||||
|
||||
a.server = mcpServer
|
||||
return a
|
||||
}
|
||||
|
||||
func (a *Adapter) ServeStdio() error {
|
||||
return server.ServeStdio(a.server)
|
||||
}
|
||||
|
||||
func (a *Adapter) MCPServer() *server.MCPServer {
|
||||
return a.server
|
||||
}
|
||||
|
||||
func (a *Adapter) handleListCapabilities(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
entityID := req.GetString("entity_id", "")
|
||||
|
||||
caps, err := a.store.ListCapabilities(entityID)
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(fmt.Sprintf("list capabilities: %v", err)), nil
|
||||
}
|
||||
|
||||
var result []map[string]any
|
||||
for _, c := range caps {
|
||||
result = append(result, map[string]any{
|
||||
"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,
|
||||
})
|
||||
}
|
||||
|
||||
data, _ := json.MarshalIndent(result, "", " ")
|
||||
return &mcp.CallToolResult{
|
||||
Content: []mcp.Content{
|
||||
mcp.TextContent{Type: "text", Text: string(data)},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *Adapter) handleInspectCapability(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
capID := req.GetString("capability_id", "")
|
||||
if capID == "" {
|
||||
return mcp.NewToolResultError("capability_id is required"), nil
|
||||
}
|
||||
|
||||
cap, err := a.store.GetCapability(capID)
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(fmt.Sprintf("capability not found: %v", err)), nil
|
||||
}
|
||||
|
||||
data, _ := json.MarshalIndent(cap, "", " ")
|
||||
return &mcp.CallToolResult{
|
||||
Content: []mcp.Content{
|
||||
mcp.TextContent{Type: "text", Text: string(data)},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *Adapter) handleResolveTarget(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
query := req.GetString("query", "")
|
||||
if query == "" {
|
||||
return mcp.NewToolResultError("query is required"), nil
|
||||
}
|
||||
|
||||
// In a real setup, this would call Nexus API.
|
||||
// For now, return a placeholder indicating Nexus resolution is needed.
|
||||
result := map[string]any{
|
||||
"query": query,
|
||||
"status": "requires_nexus_resolution",
|
||||
"message": "Connect to Nexus to resolve this query to a canonical entity ID",
|
||||
}
|
||||
|
||||
data, _ := json.MarshalIndent(result, "", " ")
|
||||
return &mcp.CallToolResult{
|
||||
Content: []mcp.Content{
|
||||
mcp.TextContent{Type: "text", Text: string(data)},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *Adapter) handleExecute(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
capID := req.GetString("capability_id", "")
|
||||
targetID := req.GetString("target_entity_id", "")
|
||||
argsStr := req.GetString("arguments", "")
|
||||
idempKey := req.GetString("idempotency_key", "")
|
||||
|
||||
if capID == "" || targetID == "" {
|
||||
return mcp.NewToolResultError("capability_id and target_entity_id are required"), nil
|
||||
}
|
||||
|
||||
args := map[string]any{}
|
||||
if argsStr != "" {
|
||||
json.Unmarshal([]byte(argsStr), &args)
|
||||
}
|
||||
|
||||
execReq := &domain.ExecuteRequest{
|
||||
CapabilityID: capID,
|
||||
TargetEntityID: targetID,
|
||||
Arguments: args,
|
||||
IdempotencyKey: idempKey,
|
||||
}
|
||||
|
||||
result, err := a.engine.Execute(execReq)
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(fmt.Sprintf("execution failed: %v", err)), nil
|
||||
}
|
||||
|
||||
data, _ := json.MarshalIndent(result.Execution, "", " ")
|
||||
return &mcp.CallToolResult{
|
||||
Content: []mcp.Content{
|
||||
mcp.TextContent{Type: "text", Text: string(data)},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *Adapter) handleExecutionStatus(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
execID := req.GetString("execution_id", "")
|
||||
if execID == "" {
|
||||
return mcp.NewToolResultError("execution_id is required"), nil
|
||||
}
|
||||
|
||||
exec, err := a.store.GetExecution(execID)
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(fmt.Sprintf("execution not found: %v", err)), nil
|
||||
}
|
||||
|
||||
data, _ := json.MarshalIndent(exec, "", " ")
|
||||
return &mcp.CallToolResult{
|
||||
Content: []mcp.Content{
|
||||
mcp.TextContent{Type: "text", Text: string(data)},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *Adapter) handleCapabilitiesResource(ctx context.Context, req mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) {
|
||||
caps, err := a.store.ListCapabilities("")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, _ := json.MarshalIndent(caps, "", " ")
|
||||
return []mcp.ResourceContents{
|
||||
mcp.TextResourceContents{
|
||||
URI: "hexis://capabilities",
|
||||
MIMEType: "application/json",
|
||||
Text: string(data),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *Adapter) handleCapabilityResourceTemplate(ctx context.Context, req mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) {
|
||||
uri := req.Params.URI
|
||||
id := strings.TrimPrefix(uri, "hexis://capabilities/")
|
||||
if id == "" {
|
||||
return nil, fmt.Errorf("invalid capability URI: %s", uri)
|
||||
}
|
||||
|
||||
cap, err := a.store.GetCapability(id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("capability %s: %w", id, err)
|
||||
}
|
||||
|
||||
data, _ := json.MarshalIndent(cap, "", " ")
|
||||
return []mcp.ResourceContents{
|
||||
mcp.TextResourceContents{
|
||||
URI: uri,
|
||||
MIMEType: "application/json",
|
||||
Text: string(data),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *Adapter) handleExecutionResourceTemplate(ctx context.Context, req mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) {
|
||||
uri := req.Params.URI
|
||||
id := strings.TrimPrefix(uri, "hexis://executions/")
|
||||
if id == "" {
|
||||
return nil, fmt.Errorf("invalid execution URI: %s", uri)
|
||||
}
|
||||
|
||||
exec, err := a.store.GetExecution(id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("execution %s: %w", id, err)
|
||||
}
|
||||
|
||||
data, _ := json.MarshalIndent(exec, "", " ")
|
||||
return []mcp.ResourceContents{
|
||||
mcp.TextResourceContents{
|
||||
URI: uri,
|
||||
MIMEType: "application/json",
|
||||
Text: string(data),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"github.com/kami/hexis/internal/domain"
|
||||
)
|
||||
|
||||
type Provider interface {
|
||||
Name() string
|
||||
Execute(capability *domain.Capability, req *domain.ExecuteRequest) (map[string]any, error)
|
||||
}
|
||||
|
||||
type Registry struct {
|
||||
mu sync.RWMutex
|
||||
providers map[string]Provider
|
||||
}
|
||||
|
||||
func NewRegistry() *Registry {
|
||||
return &Registry{
|
||||
providers: make(map[string]Provider),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Registry) Register(p Provider) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.providers[p.Name()] = p
|
||||
}
|
||||
|
||||
func (r *Registry) Get(name string) (Provider, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
p, ok := r.providers[name]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("provider %q not found", name)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (r *Registry) List() []string {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
var names []string
|
||||
for n := range r.providers {
|
||||
names = append(names, n)
|
||||
}
|
||||
return names
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/kami/hexis/internal/domain"
|
||||
)
|
||||
|
||||
type SystemdProvider struct{}
|
||||
|
||||
func NewSystemdProvider() *SystemdProvider {
|
||||
return &SystemdProvider{}
|
||||
}
|
||||
|
||||
func (p *SystemdProvider) Name() string { return "systemd" }
|
||||
|
||||
func (p *SystemdProvider) Execute(capability *domain.Capability, req *domain.ExecuteRequest) (map[string]any, error) {
|
||||
unitName := req.TargetEntityID
|
||||
if unitName == "" {
|
||||
unitName = capability.TargetEntityID
|
||||
}
|
||||
|
||||
if !isValidUnitName(unitName) {
|
||||
return nil, fmt.Errorf("invalid unit name: %q", unitName)
|
||||
}
|
||||
|
||||
var cmd *exec.Cmd
|
||||
switch capability.Operation {
|
||||
case "restart":
|
||||
cmd = exec.Command("systemctl", "restart", unitName)
|
||||
case "start":
|
||||
cmd = exec.Command("systemctl", "start", unitName)
|
||||
case "stop":
|
||||
cmd = exec.Command("systemctl", "stop", unitName)
|
||||
case "status":
|
||||
cmd = exec.Command("systemctl", "status", unitName)
|
||||
case "reload":
|
||||
cmd = exec.Command("systemctl", "reload", unitName)
|
||||
case "enable":
|
||||
cmd = exec.Command("systemctl", "enable", unitName)
|
||||
case "disable":
|
||||
cmd = exec.Command("systemctl", "disable", unitName)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported systemd operation: %s", capability.Operation)
|
||||
}
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return map[string]any{
|
||||
"stdout": string(output),
|
||||
"exit_code": cmd.ProcessState.ExitCode(),
|
||||
}, fmt.Errorf("systemctl %s failed: %w\n%s", capability.Operation, err, string(output))
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"stdout": string(output),
|
||||
"exit_code": 0,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func isValidUnitName(name string) bool {
|
||||
if name == "" || strings.Contains(name, "..") || strings.Contains(name, "/") || strings.Contains(name, ";") || strings.Contains(name, "|") || strings.Contains(name, "$") || strings.Contains(name, "`") || strings.Contains(name, "'") || strings.Contains(name, `"`) || strings.Contains(name, "\\") {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/kami/hexis/internal/domain"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
func LoadToolAllowlist(path string) (ToolAllowlist, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return ToolAllowlist{}, fmt.Errorf("read allowlist: %w", err)
|
||||
}
|
||||
var allowlist ToolAllowlist
|
||||
if err := yaml.Unmarshal(data, &allowlist); err != nil {
|
||||
return ToolAllowlist{}, fmt.Errorf("parse allowlist: %w", err)
|
||||
}
|
||||
if allowlist.Tools == nil {
|
||||
allowlist.Tools = map[string]ToolMapping{}
|
||||
}
|
||||
return allowlist, nil
|
||||
}
|
||||
|
||||
type WorkspaceMCPProvider struct {
|
||||
mu sync.RWMutex
|
||||
baseURL string
|
||||
httpClient *http.Client
|
||||
tools []WorkspaceTool
|
||||
allowlist ToolAllowlist
|
||||
}
|
||||
|
||||
type WorkspaceTool struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
InputSchema any `json:"inputSchema"`
|
||||
}
|
||||
|
||||
type ToolAllowlist struct {
|
||||
Tools map[string]ToolMapping `yaml:"tools" json:"tools"`
|
||||
}
|
||||
|
||||
type ToolMapping struct {
|
||||
Capability string `yaml:"capability" json:"capability"`
|
||||
Risk string `yaml:"risk" json:"risk"`
|
||||
TargetType string `yaml:"target_type,omitempty" json:"target_type,omitempty"`
|
||||
ReadOnly bool `yaml:"read_only" json:"read_only"`
|
||||
SideEffects string `yaml:"side_effects,omitempty" json:"side_effects,omitempty"`
|
||||
Enabled *bool `yaml:"enabled,omitempty" json:"enabled,omitempty"`
|
||||
AllowParams []string `yaml:"allow_params,omitempty" json:"allow_params,omitempty"`
|
||||
}
|
||||
|
||||
func (r *ToolAllowlist) IsEnabled(name string) bool {
|
||||
m, ok := r.Tools[name]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if m.Enabled != nil && !*m.Enabled {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (r *ToolAllowlist) Mapping(name string) (ToolMapping, bool) {
|
||||
m, ok := r.Tools[name]
|
||||
return m, ok
|
||||
}
|
||||
|
||||
func NewWorkspaceMCPProvider(baseURL string, allowlist ToolAllowlist) *WorkspaceMCPProvider {
|
||||
return &WorkspaceMCPProvider{
|
||||
baseURL: strings.TrimRight(baseURL, "/"),
|
||||
httpClient: &http.Client{
|
||||
Timeout: 60 * time.Second,
|
||||
},
|
||||
allowlist: allowlist,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *WorkspaceMCPProvider) Name() string {
|
||||
return "workspace_mcp"
|
||||
}
|
||||
|
||||
func (p *WorkspaceMCPProvider) DiscoverTools() ([]WorkspaceTool, error) {
|
||||
resp, err := p.httpClient.Get(fmt.Sprintf("%s/api/tools", p.baseURL))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("discover workspace tools: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var result struct {
|
||||
Tools []WorkspaceTool `json:"tools"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("decode workspace tools: %w", err)
|
||||
}
|
||||
|
||||
p.mu.Lock()
|
||||
p.tools = result.Tools
|
||||
p.mu.Unlock()
|
||||
|
||||
return result.Tools, nil
|
||||
}
|
||||
|
||||
func (p *WorkspaceMCPProvider) DiscoveredTools() []WorkspaceTool {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
return p.tools
|
||||
}
|
||||
|
||||
func (p *WorkspaceMCPProvider) Execute(capability *domain.Capability, req *domain.ExecuteRequest) (map[string]any, error) {
|
||||
toolName := p.capabilityToTool(capability.Name)
|
||||
if toolName == "" {
|
||||
return nil, fmt.Errorf("no workspace tool mapped for capability %q", capability.Name)
|
||||
}
|
||||
|
||||
mapping, ok := p.allowlist.Mapping(toolName)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("tool %q not in allowlist", toolName)
|
||||
}
|
||||
if !p.allowlist.IsEnabled(toolName) {
|
||||
return nil, fmt.Errorf("tool %q is disabled in allowlist", toolName)
|
||||
}
|
||||
|
||||
args := map[string]any{}
|
||||
if req.Arguments != nil {
|
||||
if len(mapping.AllowParams) > 0 {
|
||||
for k, v := range req.Arguments {
|
||||
for _, allowed := range mapping.AllowParams {
|
||||
if k == allowed {
|
||||
args[k] = v
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
args = req.Arguments
|
||||
}
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(args)
|
||||
resp, err := p.httpClient.Post(
|
||||
fmt.Sprintf("%s/api/tool/%s", p.baseURL, toolName),
|
||||
"application/json",
|
||||
bytes.NewReader(body),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("call workspace tool %q: %w", toolName, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read workspace tool response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
return map[string]any{
|
||||
"error": string(respBody),
|
||||
"http_status": resp.StatusCode,
|
||||
}, fmt.Errorf("workspace tool %q returned %d: %s", toolName, resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
var result any
|
||||
if err := json.Unmarshal(respBody, &result); err != nil {
|
||||
return map[string]any{
|
||||
"raw": string(respBody),
|
||||
}, nil
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"result": result,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *WorkspaceMCPProvider) capabilityToTool(capName string) string {
|
||||
for toolName, mapping := range p.allowlist.Tools {
|
||||
if mapping.Capability == capName {
|
||||
return toolName
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (p *WorkspaceMCPProvider) BuildCapabilities() []domain.Capability {
|
||||
var caps []domain.Capability
|
||||
for toolName, mapping := range p.allowlist.Tools {
|
||||
if !p.allowlist.IsEnabled(toolName) {
|
||||
continue
|
||||
}
|
||||
readOnly := mapping.ReadOnly
|
||||
if mapping.Risk == "read" {
|
||||
readOnly = true
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
|
||||
caps = append(caps, domain.Capability{
|
||||
ID: fmt.Sprintf("cap_ws_%s", strings.ReplaceAll(mapping.Capability, ".", "_")),
|
||||
Name: mapping.Capability,
|
||||
Description: fmt.Sprintf("Workspace tool: %s", toolName),
|
||||
TargetTypes: ifString(mapping.TargetType != "", []string{mapping.TargetType}, nil),
|
||||
TargetEntityID: "",
|
||||
Provider: "workspace_mcp",
|
||||
Operation: toolName,
|
||||
Risk: mapping.Risk,
|
||||
ReadOnly: readOnly,
|
||||
ExpectedSideEffects: mapping.SideEffects,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
Version: 1,
|
||||
})
|
||||
}
|
||||
return caps
|
||||
}
|
||||
|
||||
func ifString(cond bool, a, b []string) []string {
|
||||
if cond {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package storage
|
||||
|
||||
import "github.com/kami/hexis/internal/domain"
|
||||
|
||||
type Interface interface {
|
||||
Close() error
|
||||
CreateCapability(c *domain.Capability) error
|
||||
GetCapability(id string) (*domain.Capability, error)
|
||||
UpdateCapability(c *domain.Capability) error
|
||||
ListCapabilities(entityID string) ([]*domain.Capability, error)
|
||||
DeleteCapability(id string) error
|
||||
|
||||
CreateExecution(e *domain.Execution) error
|
||||
GetExecution(id string) (*domain.Execution, error)
|
||||
UpdateExecution(e *domain.Execution) error
|
||||
GetExecutionByIdempotencyKey(key string) (*domain.Execution, error)
|
||||
|
||||
AppendEvent(evt *domain.Event) error
|
||||
EventsAfter(seq int64, limit int) ([]*domain.Event, error)
|
||||
LatestSequence() (int64, error)
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/kami/hexis/internal/domain"
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
type Store struct {
|
||||
mu sync.RWMutex
|
||||
db *sql.DB
|
||||
path string
|
||||
}
|
||||
|
||||
func Open(path string) (*Store, error) {
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("create directory: %w", err)
|
||||
}
|
||||
|
||||
db, err := sql.Open("sqlite", path+"?_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open database: %w", err)
|
||||
}
|
||||
|
||||
db.SetMaxOpenConns(1)
|
||||
|
||||
store := &Store{db: db, path: path}
|
||||
if err := store.migrate(); err != nil {
|
||||
return nil, fmt.Errorf("migrate: %w", err)
|
||||
}
|
||||
return store, nil
|
||||
}
|
||||
|
||||
func (s *Store) Close() error {
|
||||
return s.db.Close()
|
||||
}
|
||||
|
||||
func (s *Store) DB() *sql.DB {
|
||||
return s.db
|
||||
}
|
||||
|
||||
func (s *Store) migrate() error {
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
var v int
|
||||
err = tx.QueryRow("PRAGMA user_version").Scan(&v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if v < len(migrations) {
|
||||
for i, m := range migrations[v:] {
|
||||
if _, err := tx.Exec(m); err != nil {
|
||||
return fmt.Errorf("migration %d: %w", v+i+1, err)
|
||||
}
|
||||
if _, err := tx.Exec(fmt.Sprintf("PRAGMA user_version = %d", v+i+1)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
var migrations = []string{
|
||||
`CREATE TABLE IF NOT EXISTS capabilities (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
target_types TEXT NOT NULL DEFAULT '[]',
|
||||
target_entity_id TEXT NOT NULL DEFAULT '',
|
||||
provider TEXT NOT NULL,
|
||||
operation TEXT NOT NULL,
|
||||
risk TEXT NOT NULL DEFAULT '',
|
||||
read_only INTEGER NOT NULL DEFAULT 1,
|
||||
expected_side_effects TEXT NOT NULL DEFAULT '',
|
||||
attributes TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
version INTEGER NOT NULL DEFAULT 0
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS executions (
|
||||
id TEXT PRIMARY KEY,
|
||||
capability_id TEXT NOT NULL,
|
||||
target_entity_id TEXT NOT NULL,
|
||||
entity_version INTEGER NOT NULL DEFAULT 0,
|
||||
arguments TEXT NOT NULL DEFAULT '{}',
|
||||
requested_by TEXT NOT NULL DEFAULT '{}',
|
||||
origin TEXT NOT NULL DEFAULT '{}',
|
||||
idempotency_key TEXT UNIQUE,
|
||||
status TEXT NOT NULL DEFAULT 'started',
|
||||
result TEXT NOT NULL DEFAULT '{}',
|
||||
error TEXT NOT NULL DEFAULT '',
|
||||
resolution_evidence TEXT NOT NULL DEFAULT '[]',
|
||||
correlation_id TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS hexis_events (
|
||||
id TEXT PRIMARY KEY,
|
||||
sequence INTEGER NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
timestamp TEXT NOT NULL,
|
||||
actor TEXT NOT NULL DEFAULT '',
|
||||
correlation_id TEXT NOT NULL DEFAULT '',
|
||||
causation_id TEXT NOT NULL DEFAULT '',
|
||||
payload TEXT NOT NULL DEFAULT '{}'
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_hevents_sequence ON hexis_events(sequence)`,
|
||||
`CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version INTEGER PRIMARY KEY,
|
||||
applied_at TEXT NOT NULL
|
||||
)`,
|
||||
}
|
||||
|
||||
const timeFmt = "2006-01-02T15:04:05.999999999Z07:00"
|
||||
|
||||
func formatTime(t time.Time) string {
|
||||
return t.UTC().Format(timeFmt)
|
||||
}
|
||||
|
||||
func parseTime(s string) time.Time {
|
||||
t, err := time.Parse(timeFmt, s)
|
||||
if err != nil {
|
||||
t, err = time.Parse(time.RFC3339, s)
|
||||
if err != nil {
|
||||
return time.Time{}
|
||||
}
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// Capability operations
|
||||
|
||||
func (s *Store) CreateCapability(c *domain.Capability) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
targetTypes, _ := json.Marshal(c.TargetTypes)
|
||||
attrs, _ := json.Marshal(c.Attributes)
|
||||
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO capabilities (id, name, description, target_types, target_entity_id, provider, operation, risk, read_only, expected_side_effects, attributes, created_at, updated_at, version)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
c.ID, c.Name, c.Description, string(targetTypes), c.TargetEntityID, c.Provider, c.Operation, c.Risk, boolInt(c.ReadOnly), c.ExpectedSideEffects, string(attrs), formatTime(c.CreatedAt), formatTime(c.UpdatedAt), c.Version,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) GetCapability(id string) (*domain.Capability, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
row := s.db.QueryRow(
|
||||
`SELECT id, name, description, target_types, COALESCE(target_entity_id,''), provider, operation, risk, read_only, COALESCE(expected_side_effects,''), attributes, created_at, updated_at, version
|
||||
FROM capabilities WHERE id = ?`, id,
|
||||
)
|
||||
c := &domain.Capability{}
|
||||
var targetTypes, attrs, createdAt, updatedAt string
|
||||
err := row.Scan(&c.ID, &c.Name, &c.Description, &targetTypes, &c.TargetEntityID, &c.Provider, &c.Operation, &c.Risk, &c.ReadOnly, &c.ExpectedSideEffects, &attrs, &createdAt, &updatedAt, &c.Version)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, domain.ErrCapabilityNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
json.Unmarshal([]byte(targetTypes), &c.TargetTypes)
|
||||
json.Unmarshal([]byte(attrs), &c.Attributes)
|
||||
c.CreatedAt = parseTime(createdAt)
|
||||
c.UpdatedAt = parseTime(updatedAt)
|
||||
if c.TargetTypes == nil {
|
||||
c.TargetTypes = []string{}
|
||||
}
|
||||
if c.Attributes == nil {
|
||||
c.Attributes = map[string]any{}
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (s *Store) UpdateCapability(c *domain.Capability) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
targetTypes, _ := json.Marshal(c.TargetTypes)
|
||||
attrs, _ := json.Marshal(c.Attributes)
|
||||
|
||||
res, err := s.db.Exec(
|
||||
`UPDATE capabilities SET name=?, description=?, target_types=?, target_entity_id=?, provider=?, operation=?, risk=?, read_only=?, expected_side_effects=?, attributes=?, updated_at=?, version=version+1
|
||||
WHERE id=? AND version=?`,
|
||||
c.Name, c.Description, string(targetTypes), c.TargetEntityID, c.Provider, c.Operation, c.Risk, boolInt(c.ReadOnly), c.ExpectedSideEffects, string(attrs), formatTime(c.UpdatedAt), c.ID, c.Version,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return domain.ErrConflict
|
||||
}
|
||||
c.Version++
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) ListCapabilities(entityID string) ([]*domain.Capability, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
query := `SELECT id, name, description, target_types, COALESCE(target_entity_id,''), provider, operation, risk, read_only, COALESCE(expected_side_effects,''), attributes, created_at, updated_at, version
|
||||
FROM capabilities`
|
||||
args := []any{}
|
||||
if entityID != "" {
|
||||
query += " WHERE target_entity_id = ?"
|
||||
args = append(args, entityID)
|
||||
}
|
||||
query += " ORDER BY name ASC"
|
||||
|
||||
rows, err := s.db.Query(query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var result []*domain.Capability
|
||||
for rows.Next() {
|
||||
c := &domain.Capability{}
|
||||
var targetTypes, attrs, createdAt, updatedAt string
|
||||
if err := rows.Scan(&c.ID, &c.Name, &c.Description, &targetTypes, &c.TargetEntityID, &c.Provider, &c.Operation, &c.Risk, &c.ReadOnly, &c.ExpectedSideEffects, &attrs, &createdAt, &updatedAt, &c.Version); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
json.Unmarshal([]byte(targetTypes), &c.TargetTypes)
|
||||
json.Unmarshal([]byte(attrs), &c.Attributes)
|
||||
c.CreatedAt = parseTime(createdAt)
|
||||
c.UpdatedAt = parseTime(updatedAt)
|
||||
if c.TargetTypes == nil {
|
||||
c.TargetTypes = []string{}
|
||||
}
|
||||
if c.Attributes == nil {
|
||||
c.Attributes = map[string]any{}
|
||||
}
|
||||
result = append(result, c)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Store) DeleteCapability(id string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
_, err := s.db.Exec(`DELETE FROM capabilities WHERE id = ?`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// Execution operations
|
||||
|
||||
func (s *Store) CreateExecution(e *domain.Execution) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
args, _ := json.Marshal(e.Arguments)
|
||||
reqBy, _ := json.Marshal(e.RequestedBy)
|
||||
origin, _ := json.Marshal(e.Origin)
|
||||
result, _ := json.Marshal(e.Result)
|
||||
evidence, _ := json.Marshal(e.ResolutionEvidence)
|
||||
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO executions (id, capability_id, target_entity_id, entity_version, arguments, requested_by, origin, idempotency_key, status, result, error, resolution_evidence, correlation_id, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
e.ID, e.CapabilityID, e.TargetEntityID, e.EntityVersion, string(args), string(reqBy), string(origin), nullString(e.IdempotencyKey), string(e.Status), string(result), e.Error, string(evidence), e.CorrelationID, formatTime(e.CreatedAt), formatTime(e.UpdatedAt),
|
||||
)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "UNIQUE") {
|
||||
return domain.ErrIdempotencyReplay
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) GetExecution(id string) (*domain.Execution, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
row := s.db.QueryRow(
|
||||
`SELECT id, capability_id, target_entity_id, entity_version, arguments, requested_by, origin, COALESCE(idempotency_key,''), status, result, COALESCE(error,''), resolution_evidence, COALESCE(correlation_id,''), created_at, updated_at
|
||||
FROM executions WHERE id = ?`, id,
|
||||
)
|
||||
e := &domain.Execution{}
|
||||
var args, reqBy, origin, idempKey, status, result, errStr, evidence, corrID, createdAt, updatedAt string
|
||||
err := row.Scan(&e.ID, &e.CapabilityID, &e.TargetEntityID, &e.EntityVersion, &args, &reqBy, &origin, &idempKey, &status, &result, &errStr, &evidence, &corrID, &createdAt, &updatedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, domain.ErrExecutionNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
json.Unmarshal([]byte(args), &e.Arguments)
|
||||
json.Unmarshal([]byte(reqBy), &e.RequestedBy)
|
||||
json.Unmarshal([]byte(origin), &e.Origin)
|
||||
json.Unmarshal([]byte(result), &e.Result)
|
||||
json.Unmarshal([]byte(evidence), &e.ResolutionEvidence)
|
||||
e.IdempotencyKey = idempKey
|
||||
e.Status = domain.ExecutionStatus(status)
|
||||
e.Error = errStr
|
||||
e.CorrelationID = corrID
|
||||
e.CreatedAt = parseTime(createdAt)
|
||||
e.UpdatedAt = parseTime(updatedAt)
|
||||
if e.Arguments == nil {
|
||||
e.Arguments = map[string]any{}
|
||||
}
|
||||
if e.RequestedBy == nil {
|
||||
e.RequestedBy = map[string]string{}
|
||||
}
|
||||
if e.Origin == nil {
|
||||
e.Origin = map[string]string{}
|
||||
}
|
||||
if e.Result == nil {
|
||||
e.Result = map[string]any{}
|
||||
}
|
||||
return e, nil
|
||||
}
|
||||
|
||||
func (s *Store) UpdateExecution(e *domain.Execution) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
result, _ := json.Marshal(e.Result)
|
||||
|
||||
_, err := s.db.Exec(
|
||||
`UPDATE executions SET status=?, result=?, error=?, updated_at=? WHERE id=?`,
|
||||
string(e.Status), string(result), e.Error, formatTime(e.UpdatedAt), e.ID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) GetExecutionByIdempotencyKey(key string) (*domain.Execution, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
row := s.db.QueryRow(
|
||||
`SELECT id, capability_id, target_entity_id, entity_version, arguments, requested_by, origin, idempotency_key, status, result, error, resolution_evidence, correlation_id, created_at, updated_at
|
||||
FROM executions WHERE idempotency_key = ?`, key,
|
||||
)
|
||||
e := &domain.Execution{}
|
||||
var args, reqBy, origin, idempKey, status, result, errStr, evidence, corrID, createdAt, updatedAt string
|
||||
err := row.Scan(&e.ID, &e.CapabilityID, &e.TargetEntityID, &e.EntityVersion, &args, &reqBy, &origin, &idempKey, &status, &result, &errStr, &evidence, &corrID, &createdAt, &updatedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, domain.ErrExecutionNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
json.Unmarshal([]byte(args), &e.Arguments)
|
||||
json.Unmarshal([]byte(reqBy), &e.RequestedBy)
|
||||
json.Unmarshal([]byte(origin), &e.Origin)
|
||||
json.Unmarshal([]byte(result), &e.Result)
|
||||
json.Unmarshal([]byte(evidence), &e.ResolutionEvidence)
|
||||
e.IdempotencyKey = idempKey
|
||||
e.Status = domain.ExecutionStatus(status)
|
||||
e.Error = errStr
|
||||
e.CorrelationID = corrID
|
||||
e.CreatedAt = parseTime(createdAt)
|
||||
e.UpdatedAt = parseTime(updatedAt)
|
||||
return e, nil
|
||||
}
|
||||
|
||||
// Event operations
|
||||
|
||||
func (s *Store) AppendEvent(evt *domain.Event) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
var maxSeq sql.NullInt64
|
||||
s.db.QueryRow(`SELECT MAX(sequence) FROM hexis_events`).Scan(&maxSeq)
|
||||
evt.Sequence = maxSeq.Int64 + 1
|
||||
|
||||
payload, _ := json.Marshal(evt.Payload)
|
||||
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO hexis_events (id, sequence, type, timestamp, actor, correlation_id, causation_id, payload)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
evt.ID, evt.Sequence, string(evt.Type), formatTime(evt.Timestamp), evt.Actor, evt.CorrelationID, evt.CausationID, string(payload),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) EventsAfter(seq int64, limit int) ([]*domain.Event, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
rows, err := s.db.Query(
|
||||
`SELECT id, sequence, type, timestamp, COALESCE(actor,''), COALESCE(correlation_id,''), COALESCE(causation_id,''), payload
|
||||
FROM hexis_events WHERE sequence > ? ORDER BY sequence LIMIT ?`, seq, limit,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var result []*domain.Event
|
||||
for rows.Next() {
|
||||
e := &domain.Event{}
|
||||
var typ, payload, timestamp string
|
||||
if err := rows.Scan(&e.ID, &e.Sequence, &typ, ×tamp, &e.Actor, &e.CorrelationID, &e.CausationID, &payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
e.Type = domain.HexisEventType(typ)
|
||||
e.Timestamp = parseTime(timestamp)
|
||||
json.Unmarshal([]byte(payload), &e.Payload)
|
||||
result = append(result, e)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Store) LatestSequence() (int64, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
var seq sql.NullInt64
|
||||
s.db.QueryRow(`SELECT MAX(sequence) FROM hexis_events`).Scan(&seq)
|
||||
return seq.Int64, nil
|
||||
}
|
||||
|
||||
func nullString(s string) interface{} {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func boolInt(b bool) int {
|
||||
if b {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
var _ Interface = (*Store)(nil)
|
||||
Reference in New Issue
Block a user