Fail closed on Nexus/Hexis dependency errors, accept flat resolve shape

Vikunja #268 (P0): handleHexisAct swallowed genuine Nexus resolve errors
and Hexis capability-discovery errors into "" or an empty capability list,
which fell through to the local system command executor — a dependency
outage silently looked identical to "not an ecosystem entity" or "no
capabilities registered", violating the spec's degrade-independently /
never-silent-all-clear invariant.

- resolveEntityReference's error is now distinguished from a legitimate
  not_found: only the latter falls through.
- discoverCapabilities now returns (caps, err) instead of collapsing a
  Hexis failure into an empty slice; a real error stops the action with
  a degraded-mode spoken reply instead of reaching h.tools.Exec.
- nexusResolveResult gains a custom UnmarshalJSON to accept the flat
  entity_id/entity_type/display_name shape from ECOSYSTEM-SPEC.md §1.5
  (Nexus now emits both shapes; Maven now reads both).
- Added regression tests: flat-shape resolve, Nexus error fails closed,
  Hexis error fails closed, not_found still falls through to local exec.
This commit is contained in:
kami
2026-07-20 01:08:11 +04:00
parent 44421d2048
commit d9fa4d6613
3 changed files with 141 additions and 9 deletions
+30 -5
View File
@@ -12,8 +12,8 @@ import (
"net/http"
"time"
"github.com/kami/maven/internal/config"
hexisclient "github.com/kami/hexis/pkg/client"
"github.com/kami/maven/internal/config"
)
type nexusClient struct {
@@ -49,6 +49,26 @@ type nexusResolveResult struct {
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) {
@@ -207,16 +227,21 @@ func (w *ecosystemWiring) resolveEntityReference(ctx context.Context, text strin
}
// discoverCapabilities returns Hexis capabilities applicable to an entity.
func (w *ecosystemWiring) discoverCapabilities(ctx context.Context, entityID string) []hexisclient.Capability {
// 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
return nil, nil
}
caps, err := w.hexis.Capabilities(ctx, entityID)
if err != nil {
log.Printf("ecosystem: hexis capabilities error: %v", err)
return nil
return nil, err
}
return caps
return caps, nil
}
// executeCapability runs a Hexis capability, tagging the request with a