Files
Maven/cmd/mavend/ecosystem.go
kami 0db31d21b9 hexis: re-vendor the client so a configured token is actually sent
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
2026-08-01 16:29:56 +04:00

522 lines
19 KiB
Go

package main
import (
"bytes"
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"net/url"
"time"
hexisclient "github.com/kami/hexis/pkg/client"
"github.com/kami/maven/internal/config"
)
// ecosystemCorrelationKey carries a per-call correlation ID through context
// so every ecosystem client (Nexus, Praxis, Hexis) tags its request with the
// same ID, letting a single Maven-initiated action be traced end to end.
type ecosystemCorrelationKey struct{}
func withCorrelationID(ctx context.Context, id string) context.Context {
return context.WithValue(ctx, ecosystemCorrelationKey{}, id)
}
func correlationIDFromCtx(ctx context.Context) string {
id, _ := ctx.Value(ecosystemCorrelationKey{}).(string)
return id
}
// ecosystemAPIVersion is the contract version Maven speaks to Nexus and
// Praxis. It is sent on every request so a service that has moved on can
// refuse or adapt explicitly instead of misreading an older payload.
const ecosystemAPIVersion = "v1"
// mavenRequester identifies the calling system on every ecosystem request, so
// a trace on the far side can attribute a call to Maven rather than to an
// anonymous HTTP client.
const mavenRequester = "maven"
// setEcosystemHeaders stamps the version, requester, auth and correlation
// headers common to every outgoing ecosystem request. token may be empty,
// which means the transport itself is trusted (loopback or unix socket).
//
// The correlation ID is read from the context and never minted here. Minting
// one per request sent the far side an ID that existed nowhere on this side,
// and gave a single multi-hop action as many unrelated IDs as it made calls.
// Callers that start an action assign the ID once (handleHexisAct,
// handlePraxisAct, resolveEntityReference) and every hop inherits it.
func setEcosystemHeaders(req *http.Request, ctx context.Context, versionHeader, token string) {
req.Header.Set("Content-Type", "application/json")
req.Header.Set(versionHeader, ecosystemAPIVersion)
req.Header.Set("Accept", "application/json")
req.Header.Set("X-Requested-By", mavenRequester)
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
if id := correlationIDFromCtx(ctx); id != "" {
req.Header.Set("X-Correlation-ID", id)
}
}
// ecosystemError is the typed failure every ecosystem client returns, so
// callers can tell a transport failure from a refusal from a contract
// mismatch without matching on message text. The distinction matters:
// "the service is down" and "the service rejected my version" degrade the
// same way to the user but not to whoever reads the trace.
type ecosystemError struct {
Service string // "nexus", "praxis", "hexis"
Op string // logical operation, e.g. "resolve"
Status int // HTTP status, 0 when the call never got an answer
Err error
}
func (e *ecosystemError) Error() string {
if e.Status != 0 {
return fmt.Sprintf("%s %s: http %d: %v", e.Service, e.Op, e.Status, e.Err)
}
return fmt.Sprintf("%s %s: %v", e.Service, e.Op, e.Err)
}
func (e *ecosystemError) Unwrap() error { return e.Err }
// Unauthorized reports a rejected or missing credential.
func (e *ecosystemError) Unauthorized() bool {
return e.Status == http.StatusUnauthorized || e.Status == http.StatusForbidden
}
// ContractMismatch reports that the far side refused the version Maven speaks.
func (e *ecosystemError) ContractMismatch() bool {
return e.Status == http.StatusNotAcceptable || e.Status == http.StatusUpgradeRequired
}
// Unreachable reports a call that never produced an HTTP answer at all
// (connection refused, timeout, cancelled).
func (e *ecosystemError) Unreachable() bool { return e.Status == 0 }
// httpError builds an ecosystemError from a response status.
func httpError(service, op string, status int) *ecosystemError {
return &ecosystemError{
Service: service, Op: op, Status: status,
Err: errors.New(http.StatusText(status)),
}
}
type nexusClient struct {
baseURL string
token string
httpClient *http.Client
}
func newNexusClient(url string) *nexusClient {
return &nexusClient{
baseURL: url,
httpClient: &http.Client{Timeout: 10 * time.Second},
}
}
// withToken sets the bearer token sent on every request. Returns the client so
// wiring reads as one expression.
func (c *nexusClient) withToken(token string) *nexusClient {
c.token = token
return c
}
type nexusEntity struct {
ID string `json:"id"`
Type string `json:"type"`
DisplayName string `json:"display_name"`
Key string `json:"key,omitempty"`
State string `json:"state,omitempty"`
}
type nexusCandidate struct {
EntityID string `json:"entity_id"`
DisplayName string `json:"display_name"`
Type string `json:"type"`
Score float64 `json:"score"`
Evidence string `json:"evidence"`
}
type nexusResolveResult struct {
Status string `json:"status"`
Entity *nexusEntity `json:"entity,omitempty"`
Score float64 `json:"score,omitempty"`
Candidates []nexusCandidate `json:"candidates,omitempty"`
// Flat fields per ECOSYSTEM-SPEC.md §1.5's documented resolve response
// shape. Nexus emits both this and the nested Entity above; normalize
// into Entity in UnmarshalJSON so callers only ever look at one place.
EntityID string `json:"entity_id,omitempty"`
EntityType string `json:"entity_type,omitempty"`
DisplayName string `json:"display_name,omitempty"`
}
func (r *nexusResolveResult) UnmarshalJSON(data []byte) error {
type alias nexusResolveResult
var a alias
if err := json.Unmarshal(data, &a); err != nil {
return err
}
*r = nexusResolveResult(a)
if r.Entity == nil && r.EntityID != "" {
r.Entity = &nexusEntity{ID: r.EntityID, Type: r.EntityType, DisplayName: r.DisplayName}
}
return nil
}
func (c *nexusClient) Resolve(ctx context.Context, query string, types []string) (*nexusResolveResult, error) {
body := map[string]any{"query": query}
if len(types) > 0 {
body["types"] = types
}
data, _ := json.Marshal(body)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/api/v1/resolve", bytes.NewReader(data))
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
setEcosystemHeaders(req, ctx, "X-Nexus-Version", c.token)
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, &ecosystemError{Service: "nexus", Op: "resolve", Err: err}
}
defer resp.Body.Close()
bodyBytes, _ := io.ReadAll(resp.Body)
if resp.StatusCode != 200 {
return nil, httpError("nexus", "resolve", resp.StatusCode)
}
var result nexusResolveResult
if err := json.Unmarshal(bodyBytes, &result); err != nil {
return nil, &ecosystemError{Service: "nexus", Op: "resolve", Status: resp.StatusCode, Err: err}
}
return &result, nil
}
func (c *nexusClient) Health(ctx context.Context) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/health", nil)
if err != nil {
return &ecosystemError{Service: "nexus", Op: "health", Err: err}
}
setEcosystemHeaders(req, ctx, "X-Nexus-Version", c.token)
resp, err := c.httpClient.Do(req)
if err != nil {
return &ecosystemError{Service: "nexus", Op: "health", Err: err}
}
resp.Body.Close()
if resp.StatusCode != 200 {
return httpError("nexus", "health", resp.StatusCode)
}
return nil
}
// praxisClient talks to the Praxis HTTP tools API. Maven must not open Praxis's
// SQLite store directly (ecosystem invariant: no component reads another's DB),
// so attention/changes/lifecycle all go over this HTTP contract against praxisd.
type praxisClient struct {
baseURL string
token string
httpClient *http.Client
}
func newPraxisClient(url string) *praxisClient {
return &praxisClient{
baseURL: url,
httpClient: &http.Client{Timeout: 10 * time.Second},
}
}
func (c *praxisClient) withToken(token string) *praxisClient {
c.token = token
return c
}
// getJSON performs a GET and decodes the JSON body into out. op is the logical
// operation name for errors and traces: the path carries the query string, and
// after entity scoping that means an entity id in every log line built from the
// error, next to a trace that redacts far less than that.
func (c *praxisClient) getJSON(ctx context.Context, op, path string, out any) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil)
if err != nil {
return err
}
setEcosystemHeaders(req, ctx, "X-Praxis-Version", c.token)
resp, err := c.httpClient.Do(req)
if err != nil {
return &ecosystemError{Service: "praxis", Op: op, Err: err}
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return httpError("praxis", op, resp.StatusCode)
}
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
return &ecosystemError{Service: "praxis", Op: op, Status: resp.StatusCode, Err: err}
}
return nil
}
func (c *praxisClient) ListAttention(ctx context.Context, limit int) ([]map[string]any, error) {
var out []map[string]any
err := c.getJSON(ctx, "attention", fmt.Sprintf("/api/v1/tools/attention?limit=%d", limit), &out)
return out, err
}
// ListAttentionForEntity is ListAttention scoped to a single canonical Nexus
// entity, so callers already holding a resolved entity_id (e.g. after
// resolveEntityReference) can ask "what needs attention for this entity"
// instead of filtering the unscoped list client-side.
func (c *praxisClient) ListAttentionForEntity(ctx context.Context, entityID string, limit int) ([]map[string]any, error) {
var out []map[string]any
err := c.getJSON(ctx, "attention_for_entity",
fmt.Sprintf("/api/v1/tools/attention?limit=%d&entity_id=%s", limit, url.QueryEscape(entityID)), &out)
return out, err
}
func (c *praxisClient) ListChanges(ctx context.Context, limit int) ([]map[string]any, error) {
var out []map[string]any
err := c.getJSON(ctx, "changes", fmt.Sprintf("/api/v1/tools/changes?limit=%d", limit), &out)
return out, err
}
// praxisItem is the typed shape of a Praxis item, decoded from the tools API's
// itemToMap output (pkg/tools/api.go in the praxis repo). Kept as a distinct
// type from the raw attention/changes maps above so lifecycle callers get
// compile-time field checks instead of map[string]any type assertions.
type praxisItem struct {
ID string `json:"id"`
SourceID string `json:"source_id"`
ExternalID string `json:"external_id"`
Kind string `json:"kind"`
Title string `json:"title"`
Body string `json:"body"`
State string `json:"state"`
Importance int `json:"importance"`
FirstSeenAt string `json:"first_seen_at"`
LastSeenAt string `json:"last_seen_at"`
SurfacedAt string `json:"surfaced_at"`
AckedAt string `json:"acknowledged_at"`
ResolvedAt string `json:"resolved_at"`
}
// postItemAction posts {"item_id": id} to a Praxis tools lifecycle endpoint
// and decodes the resulting item. Shared by Surface/Acknowledge/Resolve/Ignore.
func (c *praxisClient) postItemAction(ctx context.Context, op, path, itemID string) (*praxisItem, error) {
return c.postJSON(ctx, op, path, map[string]any{"item_id": itemID})
}
// postJSON posts a body to a Praxis lifecycle endpoint and decodes the item.
// Every failure is a *ecosystemError, including the transport and decode ones:
// these are the paths that mutate remote state, and the question worth
// answering afterwards is whether the call never left or was refused.
func (c *praxisClient) postJSON(ctx context.Context, op, path string, payload map[string]any) (*praxisItem, error) {
body, _ := json.Marshal(payload)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, bytes.NewReader(body))
if err != nil {
return nil, &ecosystemError{Service: "praxis", Op: op, Err: err}
}
setEcosystemHeaders(req, ctx, "X-Praxis-Version", c.token)
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, &ecosystemError{Service: "praxis", Op: op, Err: err}
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, httpError("praxis", op, resp.StatusCode)
}
var out praxisItem
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil, &ecosystemError{Service: "praxis", Op: op, Status: resp.StatusCode, Err: err}
}
return &out, nil
}
// Surface marks an item read/spoken without acknowledging it (surfaced != acknowledged,
// ECOSYSTEM-SPEC.md §2.3). Callers that read attention aloud must call this, never
// Acknowledge, so "I mentioned it" stays distinguishable from "you told me you saw it".
func (c *praxisClient) Surface(ctx context.Context, itemID string) (*praxisItem, error) {
return c.postItemAction(ctx, "surface", "/api/v1/tools/surface", itemID)
}
func (c *praxisClient) Acknowledge(ctx context.Context, itemID string) (*praxisItem, error) {
return c.postItemAction(ctx, "acknowledge", "/api/v1/tools/acknowledge", itemID)
}
func (c *praxisClient) Resolve(ctx context.Context, itemID string) (*praxisItem, error) {
return c.postItemAction(ctx, "resolve", "/api/v1/tools/resolve", itemID)
}
func (c *praxisClient) Ignore(ctx context.Context, itemID string) (*praxisItem, error) {
return c.postItemAction(ctx, "ignore", "/api/v1/tools/ignore", itemID)
}
func (c *praxisClient) Pin(ctx context.Context, itemID string, pinned bool) (*praxisItem, error) {
return c.postJSON(ctx, "pin", "/api/v1/tools/pin", map[string]any{"item_id": itemID, "pinned": pinned})
}
func (c *praxisClient) GetItem(ctx context.Context, itemID string) (*praxisItem, error) {
var out praxisItem
err := c.getJSON(ctx, "get_item", "/api/v1/tools/items/"+itemID, &out)
if err != nil {
return nil, err
}
return &out, nil
}
func (c *praxisClient) Search(ctx context.Context, query string, limit int) ([]praxisItem, error) {
var out []praxisItem
err := c.getJSON(ctx, "search", fmt.Sprintf("/api/v1/tools/search?q=%s&limit=%d", url.QueryEscape(query), limit), &out)
return out, err
}
// ecosystemWiring holds the ecosystem service clients.
type ecosystemWiring struct {
nexus *nexusClient
hexis *hexisclient.Client
praxis *praxisClient
}
func wireEcosystem(cfg *config.Config) *ecosystemWiring {
w := &ecosystemWiring{}
// Nexus identity service
if cfg.Nexus != nil && cfg.Nexus.URL != "" {
w.nexus = newNexusClient(cfg.Nexus.URL).withToken(cfg.Nexus.Token)
log.Printf("ecosystem: nexus at %s", cfg.Nexus.URL)
} else {
log.Printf("ecosystem: nexus not configured")
}
// Hexis capability service
if cfg.Hexis != nil && cfg.Hexis.URL != "" {
w.hexis = hexisclient.New(cfg.Hexis.URL).WithToken(cfg.Hexis.Token)
log.Printf("ecosystem: hexis at %s", cfg.Hexis.URL)
} else {
log.Printf("ecosystem: hexis not configured")
}
// Praxis attention service (HTTP tools API — never the DB directly)
if cfg.Praxis != nil && cfg.Praxis.URL != "" {
w.praxis = newPraxisClient(cfg.Praxis.URL).withToken(cfg.Praxis.Token)
log.Printf("ecosystem: praxis at %s", cfg.Praxis.URL)
} else {
log.Printf("ecosystem: praxis not configured")
}
return w
}
// resolveEntityReference extracts and resolves an entity name from utterance text.
// Returns the canonical entity ID on a confident resolve. On an ambiguous match it
// returns candidate display names so the caller can ask for clarification rather
// than silently guessing (ecosystem invariant: ambiguity blocks mutation).
func (w *ecosystemWiring) resolveEntityReference(ctx context.Context, text string, entityTypes []string) (entityID string, displayName string, ambiguous []string, err error) {
if w == nil || w.nexus == nil {
return "", "", nil, nil
}
if correlationIDFromCtx(ctx) == "" {
ctx = withCorrelationID(ctx, newCorrelationID())
}
result, err := w.nexus.Resolve(ctx, text, entityTypes)
if err != nil {
log.Printf("ecosystem: nexus resolve error: %v", err)
return "", "", nil, err
}
if result.Status == "resolved" {
// "resolved" with nothing to resolve to is a contract violation, not a
// miss. Treating it as "no such entity" let the caller fall straight
// through to the local executor with his verb intact, which is a
// dependency failure reaching execution.
if result.Entity == nil || result.Entity.ID == "" {
err := &ecosystemError{
Service: "nexus", Op: "resolve", Status: 200,
Err: errors.New("resolved status with no entity"),
}
log.Printf("ecosystem: %v", err)
return "", "", nil, err
}
return result.Entity.ID, result.Entity.DisplayName, nil, nil
}
if result.Status == "ambiguous" {
names := make([]string, 0, len(result.Candidates))
for _, c := range result.Candidates {
names = append(names, c.DisplayName)
}
log.Printf("ecosystem: ambiguous entity '%s' — %d candidates", text, len(names))
return "", "", names, nil
}
return "", "", nil, nil
}
// discoverCapabilities returns Hexis capabilities applicable to an entity.
// A non-nil error means Hexis could not be reached or refused the request —
// distinct from a nil error with zero capabilities, which means Hexis is
// healthy and genuinely has nothing registered for this entity. Callers must
// not conflate the two: a dependency failure must not silently read as "no
// capabilities" and fall through to unrelated local execution.
//
// The correlation header is stamped in the client's do(), so discovery and
// execution can be joined on the Hexis side as long as both hops carry the
// same ID through ctx. (This used to say the header went out on Execute only;
// that was never true of the vendored code and is not true after the 2026-08-01
// re-vendor.)
func (w *ecosystemWiring) discoverCapabilities(ctx context.Context, entityID string) ([]hexisclient.Capability, error) {
if w == nil || w.hexis == nil || entityID == "" {
return nil, nil
}
caps, err := w.hexis.Capabilities(ctx, entityID)
if err != nil {
log.Printf("ecosystem: hexis capabilities error: %v", err)
return nil, err
}
return caps, nil
}
// executeCapability runs a Hexis capability, tagging the request with a
// correlation ID so the call is traceable across services. Returns the
// correlation ID alongside the outcome.
func (w *ecosystemWiring) executeCapability(ctx context.Context, capabilityID, targetEntityID string, args map[string]any) (correlationID string, err error) {
if w == nil || w.hexis == nil {
return "", fmt.Errorf("hexis not configured")
}
correlationID = newCorrelationID()
ctx = withCorrelationID(ctx, correlationID)
ctx = hexisclient.WithCorrelationID(ctx, correlationID)
req := hexisclient.ExecuteRequest{
CapabilityID: capabilityID,
TargetEntityID: targetEntityID,
Arguments: args,
RequestedBy: map[string]string{"system": "maven", "actor": "user"},
Origin: map[string]string{"source": "voice"},
CorrelationID: correlationID,
}
exec, err := w.hexis.Execute(ctx, req)
if err != nil {
return correlationID, fmt.Errorf("execute: %w", err)
}
if exec.Status == "succeeded" {
return correlationID, nil
}
if exec.Error != "" {
return correlationID, fmt.Errorf("execution failed: %s", exec.Error)
}
return correlationID, fmt.Errorf("execution status: %s", exec.Status)
}
// newCorrelationID returns a short unique ID for cross-service call tracing.
func newCorrelationID() string {
var b [8]byte
if _, err := rand.Read(b[:]); err != nil {
return fmt.Sprintf("cor-%d", time.Now().UnixNano())
}
return "cor-" + hex.EncodeToString(b[:])
}