d9fa4d6613
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.
285 lines
8.7 KiB
Go
285 lines
8.7 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"time"
|
|
|
|
hexisclient "github.com/kami/hexis/pkg/client"
|
|
"github.com/kami/maven/internal/config"
|
|
)
|
|
|
|
type nexusClient struct {
|
|
baseURL string
|
|
httpClient *http.Client
|
|
}
|
|
|
|
func newNexusClient(url string) *nexusClient {
|
|
return &nexusClient{
|
|
baseURL: url,
|
|
httpClient: &http.Client{Timeout: 10 * time.Second},
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("do request: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
bodyBytes, _ := io.ReadAll(resp.Body)
|
|
if resp.StatusCode != 200 {
|
|
return nil, fmt.Errorf("nexus: %s", http.StatusText(resp.StatusCode))
|
|
}
|
|
|
|
var result nexusResolveResult
|
|
if err := json.Unmarshal(bodyBytes, &result); err != nil {
|
|
return nil, fmt.Errorf("decode: %w", 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
|
|
}
|
|
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
|
|
httpClient *http.Client
|
|
}
|
|
|
|
func newPraxisClient(url string) *praxisClient {
|
|
return &praxisClient{
|
|
baseURL: url,
|
|
httpClient: &http.Client{Timeout: 10 * time.Second},
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != 200 {
|
|
return fmt.Errorf("praxis: %s", http.StatusText(resp.StatusCode))
|
|
}
|
|
return json.NewDecoder(resp.Body).Decode(out)
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// 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)
|
|
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)
|
|
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)
|
|
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
|
|
}
|
|
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()
|
|
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[:])
|
|
}
|