Files
kami dda4acfbb6 Serve capabilities through one serializer and add GET /api/v1/executions
The two "refactor later" items from REVIEW-2026-07-30.md; they share the wire
types, so they land together.

A capability had four divergent wire shapes — the HTTP handler, the MCP
adapter, pkg/client, and Maven's vendored copy of it. There is now a single
definition in pkg/client, mapped from domain by internal/wire and used by the
HTTP list/create/get paths and all four MCP surfaces. It lives in pkg/client
rather than internal so external consumers need not vendor internal/domain,
and so producer and consumer are literally the same type.

The unified shape is a strict superset of all four predecessors; nothing was
dropped. It adds enabled and requires_confirmation to the list responses
(never omitempty — an absent bool reads as unknown, not false), capability_id
to the MCP and client shapes, and the timing/attribute/version fields
previously only on get-by-ID. target_types and the list itself now serialize
as [] rather than null.

Both `id` and `capability_id` are deliberately kept, carrying the same value.
Maven decodes `id`; the spec and the rest of the API say `capability_id`.
Bearer auth is already a breaking change for that consumer, and stacking a
second silent one is the wrong trade — the redundancy stays until every
consumer is confirmed on capability_id, then `id` goes in an announced
removal. A test pins this and says so.

GET /api/v1/executions?entity_id=&since=&limit= implements spec §4.5, which
the Command Center needs. `since` reuses the changes-feed cursor convention
rather than inventing a second paging idiom. That cursor is the row's implicit
SQLite rowid, which is safe only while nothing deletes executions and nothing
VACUUMs — both would renumber and silently invalidate outstanding cursors. If
retention is ever added, this must become an explicit monotonic column first;
the constraint is documented at the query site.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uea55zaiWuEByEDC4UBSdd
2026-07-30 23:40:20 +04:00

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