commit cca63269e1fbecc3b0f0531c1c0f2675f583d9a9 Author: kami Date: Mon Jul 20 00:50:37 2026 +0400 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. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0ce8b96 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +bin/ +*.db +*.db-wal +*.db-shm diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..663aa75 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/cmd/hexisctl/main.go b/cmd/hexisctl/main.go new file mode 100644 index 0000000..8c000d2 --- /dev/null +++ b/cmd/hexisctl/main.go @@ -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 [...]") + 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 ") + 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 [--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 ") + 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] [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 + exec [--args JSON] [--idempotency KEY] + execution + 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) +} diff --git a/cmd/hexisd/main.go b/cmd/hexisd/main.go new file mode 100644 index 0000000..40321e0 --- /dev/null +++ b/cmd/hexisd/main.go @@ -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") +} diff --git a/etc/workspace-allowlist.yaml b/etc/workspace-allowlist.yaml new file mode 100644 index 0000000..314409c --- /dev/null +++ b/etc/workspace-allowlist.yaml @@ -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 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..6d91b4e --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..5a4d937 --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/api/handler.go b/internal/api/handler.go new file mode 100644 index 0000000..59c82a1 --- /dev/null +++ b/internal/api/handler.go @@ -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} +} diff --git a/internal/api/server.go b/internal/api/server.go new file mode 100644 index 0000000..0711144 --- /dev/null +++ b/internal/api/server.go @@ -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 +} diff --git a/internal/domain/capability.go b/internal/domain/capability.go new file mode 100644 index 0000000..69c6533 --- /dev/null +++ b/internal/domain/capability.go @@ -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"` +} diff --git a/internal/domain/errors.go b/internal/domain/errors.go new file mode 100644 index 0000000..cb07f5c --- /dev/null +++ b/internal/domain/errors.go @@ -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") +) diff --git a/internal/domain/id.go b/internal/domain/id.go new file mode 100644 index 0000000..f60c4fb --- /dev/null +++ b/internal/domain/id.go @@ -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)) +} diff --git a/internal/execution/engine.go b/internal/execution/engine.go new file mode 100644 index 0000000..a8078b2 --- /dev/null +++ b/internal/execution/engine.go @@ -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) +} diff --git a/internal/mcp/adapter.go b/internal/mcp/adapter.go new file mode 100644 index 0000000..bf26516 --- /dev/null +++ b/internal/mcp/adapter.go @@ -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 +} diff --git a/internal/provider/registry.go b/internal/provider/registry.go new file mode 100644 index 0000000..34eb8e6 --- /dev/null +++ b/internal/provider/registry.go @@ -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 +} diff --git a/internal/provider/systemd.go b/internal/provider/systemd.go new file mode 100644 index 0000000..3f29437 --- /dev/null +++ b/internal/provider/systemd.go @@ -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 +} diff --git a/internal/provider/workspace_mcp.go b/internal/provider/workspace_mcp.go new file mode 100644 index 0000000..b980d64 --- /dev/null +++ b/internal/provider/workspace_mcp.go @@ -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 +} diff --git a/internal/storage/interface.go b/internal/storage/interface.go new file mode 100644 index 0000000..320697f --- /dev/null +++ b/internal/storage/interface.go @@ -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) +} diff --git a/internal/storage/sqlite.go b/internal/storage/sqlite.go new file mode 100644 index 0000000..bc7ce49 --- /dev/null +++ b/internal/storage/sqlite.go @@ -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) diff --git a/pkg/client/client.go b/pkg/client/client.go new file mode 100644 index 0000000..dbfc404 --- /dev/null +++ b/pkg/client/client.go @@ -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) +}