Files
Maven/cmd/mavend/ecosystem.go
T
kami 927e46bca3 Version, authenticate and fully trace ecosystem calls (#273)
Every Nexus and Praxis request now carries the contract version, an
X-Requested-By identifying Maven, a correlation ID (generated per request
when the call is not part of a traced action), and a bearer token when
one is configured. Nexus/Praxis/Hexis config blocks grew an optional
token field, env-expandable so the secret stays out of the committed
config; the vendored hexis client predates bearer auth, so a configured
Hexis token logs a loud warning instead of pretending to authenticate.

Client failures are now a typed *ecosystemError carrying service,
operation and HTTP status, classifying unauthorized, contract-mismatch
and unreachable without matching on message text.

Trace records are written for resolution, discovery, confirmation and
execution — on failure as well as success — with status, duration,
correlation and causation ids, HTTP status and failure class, and the
utterance redacted to its length. Traces were never actually persisted
before: both trace writers used fact kind "system", which the store's
CHECK constraint rejects, and the error was discarded.
2026-08-01 06:57:52 +04:00

514 lines
18 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).
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)
}
id := correlationIDFromCtx(ctx)
if id == "" {
// A call made outside a traced action still gets an ID, so the far
// side's log line can be matched to this one request.
id = newCorrelationID()
}
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 err
}
setEcosystemHeaders(req, ctx, "X-Nexus-Version", c.token)
resp, err := c.httpClient.Do(req)
if err != nil {
return err
}
resp.Body.Close()
if resp.StatusCode != 200 {
return fmt.Errorf("nexus health: %s", http.StatusText(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.
func (c *praxisClient) getJSON(ctx context.Context, 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: path, Err: err}
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return httpError("praxis", path, resp.StatusCode)
}
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
return &ecosystemError{Service: "praxis", Op: path, 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, 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, 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, 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, path, itemID string) (*praxisItem, error) {
body, _ := json.Marshal(map[string]any{"item_id": itemID})
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, bytes.NewReader(body))
if err != nil {
return nil, err
}
setEcosystemHeaders(req, ctx, "X-Praxis-Version", c.token)
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, httpError("praxis", path, resp.StatusCode)
}
var out praxisItem
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil, fmt.Errorf("decode: %w", 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, "/api/v1/tools/surface", itemID)
}
func (c *praxisClient) Acknowledge(ctx context.Context, itemID string) (*praxisItem, error) {
return c.postItemAction(ctx, "/api/v1/tools/acknowledge", itemID)
}
func (c *praxisClient) Resolve(ctx context.Context, itemID string) (*praxisItem, error) {
return c.postItemAction(ctx, "/api/v1/tools/resolve", itemID)
}
func (c *praxisClient) Ignore(ctx context.Context, itemID string) (*praxisItem, error) {
return c.postItemAction(ctx, "/api/v1/tools/ignore", itemID)
}
func (c *praxisClient) Pin(ctx context.Context, itemID string, pinned bool) (*praxisItem, error) {
body, _ := json.Marshal(map[string]any{"item_id": itemID, "pinned": pinned})
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/api/v1/tools/pin", bytes.NewReader(body))
if err != nil {
return nil, err
}
setEcosystemHeaders(req, ctx, "X-Praxis-Version", c.token)
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, httpError("praxis", "pin", resp.StatusCode)
}
var out praxisItem
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil, fmt.Errorf("decode: %w", err)
}
return &out, nil
}
func (c *praxisClient) GetItem(ctx context.Context, itemID string) (*praxisItem, error) {
var out praxisItem
err := c.getJSON(ctx, "/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, 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)
if cfg.Hexis.Token != "" {
// Upstream hexis grew Client.WithToken, but the copy vendored
// here predates it, so the token cannot be sent yet. Say so
// loudly rather than pretending the call is authenticated.
log.Printf("ecosystem: hexis token configured but the vendored hexis client cannot send it — re-vendor github.com/kami/hexis to enable bearer auth")
}
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" && result.Entity != nil {
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.
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[:])
}