diff --git a/cmd/mavend/ecosystem.go b/cmd/mavend/ecosystem.go index c249fcc..a7c36d8 100644 --- a/cmd/mavend/ecosystem.go +++ b/cmd/mavend/ecosystem.go @@ -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