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:
kami
2026-07-20 00:50:37 +04:00
commit cca63269e1
20 changed files with 2461 additions and 0 deletions
+259
View File
@@ -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}
}
+72
View File
@@ -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
}
+81
View File
@@ -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"`
}
+18
View File
@@ -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")
)
+25
View File
@@ -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))
}
+153
View File
@@ -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)
}
+298
View File
@@ -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
}
+50
View File
@@ -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
}
+68
View File
@@ -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
}
+228
View File
@@ -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
}
+21
View File
@@ -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)
}
+449
View File
@@ -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, &timestamp, &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)