0db31d21b9
The vendored copy of github.com/kami/hexis predated Client.WithToken: no token field, no setter, no header hook, and an unexported httpClient, so there was no way to attach auth from outside the package. wireEcosystem handled that by refusing to wire Hexis at all when a token was configured, which was the honest reading of the code but left the deployment silently without its executing service. go.mod already replaces the module with /home/kami/apps/hexis, and that source has had WithToken and the Bearer header for a while. Only the checked-in vendor/ copy was stale. Refreshed it (client.go plus the new capability.go) and wired Hexis like Nexus and Praxis. Two tests cover the outcome the refusal was standing in for: a configured token reaches the wire as Authorization, and no token still wires unauthed, because Hexis without auth is a valid deployment on a trusted box. Also corrected the discoverCapabilities comment. It claimed the client stamped the correlation header on Execute only; do() stamps it on every request, and did before the re-vendor too. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnX
522 lines
19 KiB
Go
522 lines
19 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).
|
|
//
|
|
// 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)
|
|
req.Header.Set("Accept", "application/json")
|
|
req.Header.Set("X-Requested-By", mavenRequester)
|
|
if token != "" {
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
}
|
|
if id := correlationIDFromCtx(ctx); id != "" {
|
|
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 &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 &ecosystemError{Service: "nexus", Op: "health", Err: err}
|
|
}
|
|
resp.Body.Close()
|
|
if resp.StatusCode != 200 {
|
|
return httpError("nexus", "health", 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. 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
|
|
}
|
|
setEcosystemHeaders(req, ctx, "X-Praxis-Version", c.token)
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return &ecosystemError{Service: "praxis", Op: op, Err: err}
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != 200 {
|
|
return httpError("praxis", op, resp.StatusCode)
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
|
|
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, "attention", 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, "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, "changes", 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, 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, &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, &ecosystemError{Service: "praxis", Op: op, Err: err}
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != 200 {
|
|
return nil, httpError("praxis", op, resp.StatusCode)
|
|
}
|
|
var out praxisItem
|
|
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
|
return nil, &ecosystemError{Service: "praxis", Op: op, Status: resp.StatusCode, Err: 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, "surface", "/api/v1/tools/surface", itemID)
|
|
}
|
|
|
|
func (c *praxisClient) Acknowledge(ctx context.Context, itemID string) (*praxisItem, error) {
|
|
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, "resolve", "/api/v1/tools/resolve", itemID)
|
|
}
|
|
|
|
func (c *praxisClient) Ignore(ctx context.Context, itemID string) (*praxisItem, error) {
|
|
return c.postItemAction(ctx, "ignore", "/api/v1/tools/ignore", itemID)
|
|
}
|
|
|
|
func (c *praxisClient) Pin(ctx context.Context, itemID string, pinned bool) (*praxisItem, error) {
|
|
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, "get_item", "/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, "search", 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).WithToken(cfg.Hexis.Token)
|
|
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" {
|
|
// "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" {
|
|
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.
|
|
//
|
|
// The correlation header is stamped in the client's do(), so discovery and
|
|
// execution can be joined on the Hexis side as long as both hops carry the
|
|
// same ID through ctx. (This used to say the header went out on Execute only;
|
|
// that was never true of the vendored code and is not true after the 2026-08-01
|
|
// re-vendor.)
|
|
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[:])
|
|
}
|