0db31d21b9
The vendored copy of github.com/kami/hexis predated Client.WithToken: no token field, no setter, no header hook, and an unexported httpClient, so there was no way to attach auth from outside the package. wireEcosystem handled that by refusing to wire Hexis at all when a token was configured, which was the honest reading of the code but left the deployment silently without its executing service. go.mod already replaces the module with /home/kami/apps/hexis, and that source has had WithToken and the Bearer header for a while. Only the checked-in vendor/ copy was stale. Refreshed it (client.go plus the new capability.go) and wired Hexis like Nexus and Praxis. Two tests cover the outcome the refusal was standing in for: a configured token reaches the wire as Authorization, and no token still wires unauthed, because Hexis without auth is a valid deployment on a trusted box. Also corrected the discoverCapabilities comment. It claimed the client stamped the correlation header on Execute only; do() stamps it on every request, and did before the re-vendor too. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnX
228 lines
6.9 KiB
Go
228 lines
6.9 KiB
Go
package client
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"time"
|
|
)
|
|
|
|
// APIVersion is sent as X-Hexis-Version on every request so the server can
|
|
// negotiate/reject unsupported client versions.
|
|
const APIVersion = "v1"
|
|
|
|
type correlationKey struct{}
|
|
type causationKey struct{}
|
|
|
|
// WithCorrelationID returns a context that carries a correlation ID to be
|
|
// sent as X-Correlation-ID on every Hexis request made with it.
|
|
func WithCorrelationID(ctx context.Context, id string) context.Context {
|
|
return context.WithValue(ctx, correlationKey{}, id)
|
|
}
|
|
|
|
// WithCausationID returns a context that carries a causation ID to be sent
|
|
// as X-Causation-ID on every Hexis request made with it.
|
|
func WithCausationID(ctx context.Context, id string) context.Context {
|
|
return context.WithValue(ctx, causationKey{}, id)
|
|
}
|
|
|
|
func correlationIDFrom(ctx context.Context) string {
|
|
id, _ := ctx.Value(correlationKey{}).(string)
|
|
return id
|
|
}
|
|
|
|
func causationIDFrom(ctx context.Context) string {
|
|
id, _ := ctx.Value(causationKey{}).(string)
|
|
return id
|
|
}
|
|
|
|
type Client struct {
|
|
baseURL string
|
|
httpClient *http.Client
|
|
token string
|
|
}
|
|
|
|
func New(baseURL string) *Client {
|
|
return &Client{
|
|
baseURL: baseURL,
|
|
httpClient: &http.Client{Timeout: 30 * time.Second},
|
|
}
|
|
}
|
|
|
|
// WithToken sets the shared bearer token sent on every /api/v1/ request.
|
|
func (c *Client) WithToken(token string) *Client {
|
|
c.token = token
|
|
return c
|
|
}
|
|
|
|
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")
|
|
req.Header.Set("X-Hexis-Version", APIVersion)
|
|
if c.token != "" {
|
|
req.Header.Set("Authorization", "Bearer "+c.token)
|
|
}
|
|
if id := correlationIDFrom(ctx); id != "" {
|
|
req.Header.Set("X-Correlation-ID", id)
|
|
}
|
|
if id := causationIDFrom(ctx); id != "" {
|
|
req.Header.Set("X-Causation-ID", id)
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// Capability is defined in capability.go — the single wire shape shared by
|
|
// the HTTP handler, the MCP adapter and this client.
|
|
|
|
type Execution struct {
|
|
// Seq is the pagination cursor for Executions; see the `since` parameter.
|
|
// Zero on single-execution reads.
|
|
Seq int64 `json:"seq,omitempty"`
|
|
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"`
|
|
CausationID string `json:"causation_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"`
|
|
CausationID string `json:"causation_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=" + url.QueryEscape(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
|
|
}
|
|
|
|
// Executions returns execution history, newest last, ordered by ascending
|
|
// `seq` (ECOSYSTEM-SPEC.md §4.5).
|
|
//
|
|
// entityID, when non-empty, filters to executions against that target entity.
|
|
// since is an exclusive cursor: pass 0 for the first page, then the Seq of the
|
|
// last element returned. The server caps a page at 100 rows, so a full page
|
|
// means "call again with the new cursor".
|
|
func (c *Client) Executions(ctx context.Context, entityID string, since int64) ([]Execution, error) {
|
|
q := url.Values{}
|
|
if entityID != "" {
|
|
q.Set("entity_id", entityID)
|
|
}
|
|
if since > 0 {
|
|
q.Set("since", strconv.FormatInt(since, 10))
|
|
}
|
|
path := "/api/v1/executions"
|
|
if len(q) > 0 {
|
|
path += "?" + q.Encode()
|
|
}
|
|
|
|
var result []Execution
|
|
if err := c.do(ctx, http.MethodGet, path, 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)
|
|
}
|