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) }