Version, authenticate and fully trace ecosystem calls #84
+72
-55
@@ -45,6 +45,12 @@ 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)
|
||||
@@ -53,13 +59,9 @@ func setEcosystemHeaders(req *http.Request, ctx context.Context, versionHeader,
|
||||
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()
|
||||
if id := correlationIDFromCtx(ctx); id != "" {
|
||||
req.Header.Set("X-Correlation-ID", id)
|
||||
}
|
||||
req.Header.Set("X-Correlation-ID", id)
|
||||
}
|
||||
|
||||
// ecosystemError is the typed failure every ecosystem client returns, so
|
||||
@@ -202,16 +204,16 @@ func (c *nexusClient) Resolve(ctx context.Context, query string, types []string)
|
||||
func (c *nexusClient) Health(ctx context.Context) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/health", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
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 err
|
||||
return &ecosystemError{Service: "nexus", Op: "health", Err: err}
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
return fmt.Errorf("nexus health: %s", http.StatusText(resp.StatusCode))
|
||||
return httpError("nexus", "health", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -237,8 +239,11 @@ func (c *praxisClient) withToken(token string) *praxisClient {
|
||||
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 {
|
||||
// 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
|
||||
@@ -246,21 +251,21 @@ func (c *praxisClient) getJSON(ctx context.Context, path string, out any) error
|
||||
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}
|
||||
return &ecosystemError{Service: "praxis", Op: op, Err: err}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
return httpError("praxis", path, resp.StatusCode)
|
||||
return httpError("praxis", op, resp.StatusCode)
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
|
||||
return &ecosystemError{Service: "praxis", Op: path, Status: resp.StatusCode, Err: err}
|
||||
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, fmt.Sprintf("/api/v1/tools/attention?limit=%d", limit), &out)
|
||||
err := c.getJSON(ctx, "attention", fmt.Sprintf("/api/v1/tools/attention?limit=%d", limit), &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
@@ -270,13 +275,14 @@ func (c *praxisClient) ListAttention(ctx context.Context, limit int) ([]map[stri
|
||||
// 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)
|
||||
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, fmt.Sprintf("/api/v1/tools/changes?limit=%d", limit), &out)
|
||||
err := c.getJSON(ctx, "changes", fmt.Sprintf("/api/v1/tools/changes?limit=%d", limit), &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
@@ -302,24 +308,32 @@ type praxisItem struct {
|
||||
|
||||
// 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})
|
||||
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, err
|
||||
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, err
|
||||
return nil, &ecosystemError{Service: "praxis", Op: op, Err: err}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, httpError("praxis", path, resp.StatusCode)
|
||||
return nil, httpError("praxis", op, resp.StatusCode)
|
||||
}
|
||||
var out praxisItem
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
return nil, fmt.Errorf("decode: %w", err)
|
||||
return nil, &ecosystemError{Service: "praxis", Op: op, Status: resp.StatusCode, Err: err}
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
@@ -328,46 +342,28 @@ func (c *praxisClient) postItemAction(ctx context.Context, path, itemID string)
|
||||
// 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)
|
||||
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, "/api/v1/tools/acknowledge", itemID)
|
||||
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, "/api/v1/tools/resolve", itemID)
|
||||
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, "/api/v1/tools/ignore", itemID)
|
||||
return c.postItemAction(ctx, "ignore", "/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
|
||||
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, "/api/v1/tools/items/"+itemID, &out)
|
||||
err := c.getJSON(ctx, "get_item", "/api/v1/tools/items/"+itemID, &out)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -376,7 +372,7 @@ func (c *praxisClient) GetItem(ctx context.Context, itemID string) (*praxisItem,
|
||||
|
||||
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)
|
||||
err := c.getJSON(ctx, "search", fmt.Sprintf("/api/v1/tools/search?q=%s&limit=%d", url.QueryEscape(query), limit), &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
@@ -400,14 +396,18 @@ func wireEcosystem(cfg *config.Config) *ecosystemWiring {
|
||||
|
||||
// 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")
|
||||
// Upstream hexis grew Client.WithToken, but the copy vendored here
|
||||
// predates it, so the token cannot be sent. Hexis is the only one
|
||||
// of the three that executes anything, and configuring auth on the
|
||||
// executing service is not a best-effort request: refuse to wire it
|
||||
// rather than execute unauthenticated for weeks behind one boot-time
|
||||
// log line.
|
||||
log.Printf("ecosystem: hexis token configured but the vendored hexis client cannot send it — hexis stays disabled; re-vendor github.com/kami/hexis to enable bearer auth")
|
||||
} else {
|
||||
w.hexis = hexisclient.New(cfg.Hexis.URL)
|
||||
log.Printf("ecosystem: hexis at %s", cfg.Hexis.URL)
|
||||
}
|
||||
log.Printf("ecosystem: hexis at %s", cfg.Hexis.URL)
|
||||
} else {
|
||||
log.Printf("ecosystem: hexis not configured")
|
||||
}
|
||||
@@ -439,7 +439,19 @@ func (w *ecosystemWiring) resolveEntityReference(ctx context.Context, text strin
|
||||
log.Printf("ecosystem: nexus resolve error: %v", err)
|
||||
return "", "", nil, err
|
||||
}
|
||||
if result.Status == "resolved" && result.Entity != nil {
|
||||
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" {
|
||||
@@ -459,6 +471,11 @@ func (w *ecosystemWiring) resolveEntityReference(ctx context.Context, text strin
|
||||
// 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 vendored Hexis client stamps a correlation header on Execute only, so
|
||||
// discovery and execution cannot be joined on the Hexis side. Maven's own
|
||||
// traces still share one ID for both hops; the gap is on the far side and
|
||||
// closes when the client is re-vendored.
|
||||
func (w *ecosystemWiring) discoverCapabilities(ctx context.Context, entityID string) ([]hexisclient.Capability, error) {
|
||||
if w == nil || w.hexis == nil || entityID == "" {
|
||||
return nil, nil
|
||||
|
||||
Reference in New Issue
Block a user