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,4 @@
|
||||
bin/
|
||||
*.db
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
# Pure-Go (modernc sqlite) ⇒ CGO_ENABLED=0 static build, distroless output.
|
||||
FROM golang:1.25-alpine AS build
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/hexisd ./cmd/hexisd
|
||||
|
||||
FROM gcr.io/distroless/static-debian12
|
||||
COPY --from=build /out/hexisd /hexisd
|
||||
# WORKSPACE_MCP_URL is read from env (see compose) to wire the workspace MCP
|
||||
# provider; -http 0.0.0.0 so nginx/Maven can reach the capability API.
|
||||
ENTRYPOINT ["/hexisd", "-data", "/data", "-http", "0.0.0.0:9741"]
|
||||
@@ -0,0 +1,230 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kami/hexis/pkg/client"
|
||||
)
|
||||
|
||||
var cli *client.Client
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
printUsage()
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
baseURL := os.Getenv("HEXIS_URL")
|
||||
if baseURL == "" {
|
||||
baseURL = "http://localhost:9741"
|
||||
}
|
||||
if os.Args[1] == "--url" && len(os.Args) > 2 {
|
||||
baseURL = os.Args[2]
|
||||
os.Args = append(os.Args[:1], os.Args[3:]...)
|
||||
}
|
||||
|
||||
cli = client.New(baseURL)
|
||||
|
||||
cmd := os.Args[1]
|
||||
args := os.Args[2:]
|
||||
|
||||
switch cmd {
|
||||
case "capability":
|
||||
handleCapability(args)
|
||||
case "exec":
|
||||
handleExec(args)
|
||||
case "execution":
|
||||
handleExecution(args)
|
||||
case "health":
|
||||
handleHealth()
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "unknown command: %s\n", cmd)
|
||||
printUsage()
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func handleCapability(args []string) {
|
||||
if len(args) < 1 {
|
||||
fmt.Fprintln(os.Stderr, "usage: hexis capability <create|list|show|delete> [...]")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
sub := args[0]
|
||||
rest := args[1:]
|
||||
|
||||
switch sub {
|
||||
case "create":
|
||||
capCreate(rest)
|
||||
case "list":
|
||||
capList(rest)
|
||||
case "show":
|
||||
capShow(rest)
|
||||
case "delete":
|
||||
capDelete(rest)
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "unknown capability subcommand: %s\n", sub)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func capCreate(args []string) {
|
||||
flags := parseFlags(args)
|
||||
req := client.CreateCapabilityRequest{
|
||||
Name: flags["name"],
|
||||
Description: flags["description"],
|
||||
TargetEntityID: flags["target"],
|
||||
Provider: flags["provider"],
|
||||
Operation: flags["operation"],
|
||||
Risk: flags["risk"],
|
||||
ExpectedSideEffects: flags["side-effects"],
|
||||
}
|
||||
if flags["read-only"] == "true" {
|
||||
req.ReadOnly = true
|
||||
}
|
||||
if typesStr := flags["types"]; typesStr != "" {
|
||||
req.TargetTypes = strings.Split(typesStr, ",")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
cap, err := cli.CreateCapability(ctx, req)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
printJSON(cap)
|
||||
}
|
||||
|
||||
func capList(args []string) {
|
||||
flags := parseFlags(args)
|
||||
entityID := flags["entity"]
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
caps, err := cli.Capabilities(ctx, entityID)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
printJSON(caps)
|
||||
}
|
||||
|
||||
func capShow(args []string) {
|
||||
if len(args) < 1 {
|
||||
fmt.Fprintln(os.Stderr, "usage: hexis capability show <id>")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
cap, err := cli.GetCapability(ctx, args[0])
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
printJSON(cap)
|
||||
}
|
||||
|
||||
func capDelete(args []string) {
|
||||
fmt.Fprintln(os.Stderr, "capability delete not yet implemented")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
func handleExec(args []string) {
|
||||
if len(args) < 2 {
|
||||
fmt.Fprintln(os.Stderr, "usage: hexis exec <capability-id> <target-entity-id> [--args JSON] [--idempotency KEY]")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
flags := parseFlags(args[2:])
|
||||
req := client.ExecuteRequest{
|
||||
CapabilityID: args[0],
|
||||
TargetEntityID: args[1],
|
||||
IdempotencyKey: flags["idempotency"],
|
||||
}
|
||||
if argsStr := flags["args"]; argsStr != "" {
|
||||
json.Unmarshal([]byte(argsStr), &req.Arguments)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
result, err := cli.Execute(ctx, req)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
printJSON(result)
|
||||
}
|
||||
|
||||
func handleExecution(args []string) {
|
||||
if len(args) < 1 {
|
||||
fmt.Fprintln(os.Stderr, "usage: hexis execution <id>")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
exec, err := cli.GetExecution(ctx, args[0])
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
printJSON(exec)
|
||||
}
|
||||
|
||||
func handleHealth() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := cli.Health(ctx); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "unhealthy: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println("ok")
|
||||
}
|
||||
|
||||
func printUsage() {
|
||||
fmt.Fprintf(os.Stderr, `Usage: hexis [--url URL] <command> [args]
|
||||
|
||||
Commands:
|
||||
capability create --name NAME --provider PROVIDER --operation OP [--target ENTITY] [--types TYPES] [--risk RISK] [--read-only true] [--side-effects TEXT]
|
||||
capability list [--entity ENTITY_ID]
|
||||
capability show <id>
|
||||
exec <capability-id> <target-entity-id> [--args JSON] [--idempotency KEY]
|
||||
execution <id>
|
||||
health
|
||||
`)
|
||||
}
|
||||
|
||||
func parseFlags(args []string) map[string]string {
|
||||
flags := map[string]string{}
|
||||
for i := 0; i < len(args); i++ {
|
||||
if strings.HasPrefix(args[i], "--") {
|
||||
key := strings.TrimPrefix(args[i], "--")
|
||||
if i+1 < len(args) && !strings.HasPrefix(args[i+1], "--") {
|
||||
flags[key] = args[i+1]
|
||||
i++
|
||||
} else {
|
||||
flags[key] = "true"
|
||||
}
|
||||
}
|
||||
}
|
||||
return flags
|
||||
}
|
||||
|
||||
func printJSON(v any) {
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
enc.Encode(v)
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
|
||||
"github.com/kami/hexis/internal/api"
|
||||
"github.com/kami/hexis/internal/execution"
|
||||
"github.com/kami/hexis/internal/mcp"
|
||||
"github.com/kami/hexis/internal/provider"
|
||||
"github.com/kami/hexis/internal/storage"
|
||||
)
|
||||
|
||||
func main() {
|
||||
var httpAddr string
|
||||
var dataDir string
|
||||
var mcpMode bool
|
||||
var workspaceURL string
|
||||
var workspaceAllowlist string
|
||||
|
||||
flag.StringVar(&httpAddr, "http", "", "HTTP listen address (default localhost:9741)")
|
||||
flag.StringVar(&dataDir, "data", "", "Data directory for SQLite database")
|
||||
flag.BoolVar(&mcpMode, "mcp", false, "Run in MCP stdio mode")
|
||||
flag.StringVar(&workspaceURL, "workspace-url", "", "Workspace MCP HTTP API URL (e.g. http://localhost:9930)")
|
||||
flag.StringVar(&workspaceAllowlist, "workspace-allowlist", "", "Path to workspace tool allowlist YAML")
|
||||
flag.Parse()
|
||||
|
||||
if dataDir == "" {
|
||||
dataDir = filepath.Join(os.Getenv("HOME"), ".local", "share", "hexis")
|
||||
}
|
||||
if httpAddr == "" {
|
||||
httpAddr = "localhost:9741"
|
||||
}
|
||||
if workspaceAllowlist == "" {
|
||||
workspaceAllowlist = filepath.Join(dataDir, "workspace-allowlist.yaml")
|
||||
}
|
||||
if workspaceURL == "" {
|
||||
workspaceURL = os.Getenv("WORKSPACE_MCP_URL")
|
||||
}
|
||||
|
||||
dbPath := filepath.Join(dataDir, "hexis.db")
|
||||
|
||||
store, err := storage.Open(dbPath)
|
||||
if err != nil {
|
||||
log.Fatalf("open storage: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
reg := provider.NewRegistry()
|
||||
reg.Register(provider.NewSystemdProvider())
|
||||
|
||||
// Register workspace MCP provider if configured
|
||||
if workspaceURL != "" {
|
||||
allowlist, err := provider.LoadToolAllowlist(workspaceAllowlist)
|
||||
if err != nil {
|
||||
log.Fatalf("load workspace allowlist: %v", err)
|
||||
}
|
||||
wsProvider := provider.NewWorkspaceMCPProvider(workspaceURL, allowlist)
|
||||
reg.Register(wsProvider)
|
||||
|
||||
tools, err := wsProvider.DiscoverTools()
|
||||
if err != nil {
|
||||
log.Printf("warning: workspace MCP discovery failed: %v", err)
|
||||
} else {
|
||||
log.Printf("discovered %d workspace tools, %d in allowlist", len(tools), len(allowlist.Tools))
|
||||
}
|
||||
|
||||
// Register workspace capabilities
|
||||
for _, cap := range wsProvider.BuildCapabilities() {
|
||||
existing, err := store.GetCapability(cap.ID)
|
||||
if err == nil && existing != nil {
|
||||
continue
|
||||
}
|
||||
if err := store.CreateCapability(&cap); err != nil {
|
||||
log.Printf("warning: register capability %s: %v", cap.Name, err)
|
||||
} else {
|
||||
log.Printf("registered capability: %s -> %s", cap.Name, cap.Operation)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
engine := execution.New(store, reg)
|
||||
|
||||
if mcpMode {
|
||||
log.Printf("starting MCP stdio adapter")
|
||||
adapter := mcp.New(store, engine)
|
||||
if err := adapter.ServeStdio(); err != nil {
|
||||
log.Fatalf("MCP error: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
srv := api.NewServer(store, engine)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
go func() {
|
||||
log.Printf("listening on http %s", httpAddr)
|
||||
if err := srv.ListenHTTP(httpAddr); err != nil {
|
||||
log.Printf("http error: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
sig := make(chan os.Signal, 1)
|
||||
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-sig
|
||||
|
||||
log.Println("shutting down...")
|
||||
srv.Shutdown(ctx)
|
||||
log.Println("stopped")
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
tools:
|
||||
# Docker
|
||||
docker.list_containers:
|
||||
capability: workspace.docker.list
|
||||
risk: read
|
||||
read_only: true
|
||||
|
||||
docker.inspect_container:
|
||||
capability: workspace.docker.inspect
|
||||
risk: read
|
||||
read_only: true
|
||||
|
||||
docker.restart_container:
|
||||
capability: workspace.docker.restart
|
||||
risk: medium
|
||||
target_type: container
|
||||
side_effects: container restart
|
||||
|
||||
docker.start_container:
|
||||
capability: workspace.docker.start
|
||||
risk: medium
|
||||
target_type: container
|
||||
side_effects: container start
|
||||
|
||||
docker.stop_container:
|
||||
capability: workspace.docker.stop
|
||||
risk: medium
|
||||
target_type: container
|
||||
side_effects: container stop
|
||||
|
||||
# Git
|
||||
git_summary:
|
||||
capability: workspace.git.summary
|
||||
risk: read
|
||||
read_only: true
|
||||
|
||||
git_history:
|
||||
capability: workspace.git.history
|
||||
risk: read
|
||||
read_only: true
|
||||
|
||||
git_diff:
|
||||
capability: workspace.git.diff
|
||||
risk: read
|
||||
read_only: true
|
||||
|
||||
# Vikunja
|
||||
task_list:
|
||||
capability: workspace.vikunja.list_tasks
|
||||
risk: read
|
||||
read_only: true
|
||||
|
||||
task_get:
|
||||
capability: workspace.vikunja.get_task
|
||||
risk: read
|
||||
read_only: true
|
||||
|
||||
task_create:
|
||||
capability: workspace.vikunja.create_task
|
||||
risk: low
|
||||
side_effects: task created in Vikunja
|
||||
|
||||
# Host / metrics
|
||||
host_info:
|
||||
capability: workspace.host.info
|
||||
risk: read
|
||||
read_only: true
|
||||
|
||||
# Project context
|
||||
project_overview:
|
||||
capability: workspace.project.overview
|
||||
risk: read
|
||||
read_only: true
|
||||
|
||||
project_search:
|
||||
capability: workspace.project.search
|
||||
risk: read
|
||||
read_only: true
|
||||
|
||||
# Knowledge
|
||||
knowledge_search:
|
||||
capability: workspace.knowledge.search
|
||||
risk: read
|
||||
read_only: true
|
||||
|
||||
# Logs
|
||||
logs_search:
|
||||
capability: workspace.logs.search
|
||||
risk: read
|
||||
read_only: true
|
||||
|
||||
# High-risk (off by default)
|
||||
ssh.exec:
|
||||
capability: workspace.ssh.exec
|
||||
risk: high
|
||||
enabled: false
|
||||
|
||||
# Metrics
|
||||
metrics_query:
|
||||
capability: workspace.metrics.query
|
||||
risk: read
|
||||
read_only: true
|
||||
|
||||
metrics_compare:
|
||||
capability: workspace.metrics.compare
|
||||
risk: read
|
||||
read_only: true
|
||||
@@ -0,0 +1,26 @@
|
||||
module github.com/kami/hexis
|
||||
|
||||
go 1.25.5
|
||||
|
||||
require (
|
||||
github.com/mark3labs/mcp-go v0.56.0
|
||||
modernc.org/sqlite v1.54.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/google/jsonschema-go v0.4.2 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
|
||||
github.com/spf13/cast v1.7.1 // indirect
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
||||
golang.org/x/sys v0.46.0 // indirect
|
||||
golang.org/x/text v0.14.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
modernc.org/libc v1.74.1 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
)
|
||||
@@ -0,0 +1,84 @@
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
|
||||
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
|
||||
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/mark3labs/mcp-go v0.56.0 h1:7aCj2wODCskMi08f923ADG+EfELZBdiKILny415cIS8=
|
||||
github.com/mark3labs/mcp-go v0.56.0/go.mod h1:+8WclSK1ZUweCP3hvktSji8n8ABG/95QaEkeVE/Uwas=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
|
||||
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
|
||||
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
|
||||
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
||||
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
||||
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/cc/v4 v4.29.0 h1:CXgwL8cvxmyzBQZzbSl/6xFtMCryb6u8IOqDci39cgc=
|
||||
modernc.org/cc/v4 v4.29.0/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||
modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
|
||||
modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk=
|
||||
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI=
|
||||
modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.74.1 h1:bdR4VTKFMC4966QSNZ05XLGI/VwzVa2kTUX51Dm0riQ=
|
||||
modernc.org/libc v1.74.1/go.mod h1:uH4t5bOx3G3g9Xcmj10YKlTcVISlRDwv8VoQJG9n8Os=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
||||
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.54.0 h1:JCxR4qwkJvOaqAoYcgDoO25Nc+ROg6EJ2LfBVzdrgog=
|
||||
modernc.org/sqlite v1.54.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
@@ -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)
|
||||
@@ -0,0 +1,157 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
baseURL string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func New(baseURL string) *Client {
|
||||
return &Client{
|
||||
baseURL: baseURL,
|
||||
httpClient: &http.Client{Timeout: 30 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) do(ctx context.Context, method, path string, body, result any) error {
|
||||
var reqBody io.Reader
|
||||
if body != nil {
|
||||
data, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal request: %w", err)
|
||||
}
|
||||
reqBody = bytes.NewReader(data)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, reqBody)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("do request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
var errResp struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
json.NewDecoder(resp.Body).Decode(&errResp)
|
||||
if errResp.Error != "" {
|
||||
return fmt.Errorf("%s: %s", http.StatusText(resp.StatusCode), errResp.Error)
|
||||
}
|
||||
return fmt.Errorf("%s", http.StatusText(resp.StatusCode))
|
||||
}
|
||||
|
||||
if result != nil {
|
||||
return json.NewDecoder(resp.Body).Decode(result)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
type Execution struct {
|
||||
ID string `json:"id"`
|
||||
CapabilityID string `json:"capability_id"`
|
||||
TargetEntityID string `json:"target_entity_id"`
|
||||
Status string `json:"status"`
|
||||
Result map[string]any `json:"result,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
RequestedBy map[string]string `json:"requested_by,omitempty"`
|
||||
CorrelationID string `json:"correlation_id,omitempty"`
|
||||
IdempotencyKey string `json:"idempotency_key,omitempty"`
|
||||
}
|
||||
|
||||
type ExecuteRequest struct {
|
||||
CapabilityID string `json:"capability_id"`
|
||||
TargetEntityID string `json:"target_entity_id"`
|
||||
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"`
|
||||
}
|
||||
|
||||
type CreateCapabilityRequest 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"`
|
||||
}
|
||||
|
||||
func (c *Client) Capabilities(ctx context.Context, entityID string) ([]Capability, error) {
|
||||
var result []Capability
|
||||
path := "/api/v1/capabilities"
|
||||
if entityID != "" {
|
||||
path += "?entity_id=" + entityID
|
||||
}
|
||||
if err := c.do(ctx, http.MethodGet, path, nil, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (c *Client) GetCapability(ctx context.Context, id string) (*Capability, error) {
|
||||
var result Capability
|
||||
if err := c.do(ctx, http.MethodGet, "/api/v1/capabilities/"+id, nil, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func (c *Client) CreateCapability(ctx context.Context, req CreateCapabilityRequest) (*Capability, error) {
|
||||
var result Capability
|
||||
if err := c.do(ctx, http.MethodPost, "/api/v1/capabilities", req, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func (c *Client) Execute(ctx context.Context, req ExecuteRequest) (*Execution, error) {
|
||||
var result Execution
|
||||
if err := c.do(ctx, http.MethodPost, "/api/v1/execute", req, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func (c *Client) GetExecution(ctx context.Context, id string) (*Execution, error) {
|
||||
var result Execution
|
||||
if err := c.do(ctx, http.MethodGet, "/api/v1/executions/"+id, nil, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func (c *Client) Health(ctx context.Context) error {
|
||||
return c.do(ctx, http.MethodGet, "/health", nil, nil)
|
||||
}
|
||||
Reference in New Issue
Block a user