Version, authenticate and fully trace ecosystem calls (#273)

Every Nexus and Praxis request now carries the contract version, an
X-Requested-By identifying Maven, a correlation ID (generated per request
when the call is not part of a traced action), and a bearer token when
one is configured. Nexus/Praxis/Hexis config blocks grew an optional
token field, env-expandable so the secret stays out of the committed
config; the vendored hexis client predates bearer auth, so a configured
Hexis token logs a loud warning instead of pretending to authenticate.

Client failures are now a typed *ecosystemError carrying service,
operation and HTTP status, classifying unauthorized, contract-mismatch
and unreachable without matching on message text.

Trace records are written for resolution, discovery, confirmation and
execution — on failure as well as success — with status, duration,
correlation and causation ids, HTTP status and failure class, and the
utterance redacted to its length. Traces were never actually persisted
before: both trace writers used fact kind "system", which the store's
CHECK constraint rejects, and the error was discarded.
This commit is contained in:
kami
2026-08-01 06:57:52 +04:00
parent 08f3db318f
commit 927e46bca3
4 changed files with 523 additions and 24 deletions
+108 -21
View File
@@ -6,6 +6,7 @@ import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"log"
@@ -31,18 +32,82 @@ func correlationIDFromCtx(ctx context.Context) string {
return id
}
// setEcosystemHeaders stamps the version and correlation headers common to
// every outgoing ecosystem request.
func setEcosystemHeaders(req *http.Request, ctx context.Context, versionHeader string) {
// 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).
func setEcosystemHeaders(req *http.Request, ctx context.Context, versionHeader, token string) {
req.Header.Set("Content-Type", "application/json")
req.Header.Set(versionHeader, "v1")
if id := correlationIDFromCtx(ctx); id != "" {
req.Header.Set("X-Correlation-ID", id)
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)
}
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()
}
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
}
@@ -53,6 +118,13 @@ func newNexusClient(url string) *nexusClient {
}
}
// 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"`
@@ -107,22 +179,22 @@ func (c *nexusClient) Resolve(ctx context.Context, query string, types []string)
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
setEcosystemHeaders(req, ctx, "X-Nexus-Version")
setEcosystemHeaders(req, ctx, "X-Nexus-Version", c.token)
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("do request: %w", err)
return nil, &ecosystemError{Service: "nexus", Op: "resolve", Err: err}
}
defer resp.Body.Close()
bodyBytes, _ := io.ReadAll(resp.Body)
if resp.StatusCode != 200 {
return nil, fmt.Errorf("nexus: %s", http.StatusText(resp.StatusCode))
return nil, httpError("nexus", "resolve", resp.StatusCode)
}
var result nexusResolveResult
if err := json.Unmarshal(bodyBytes, &result); err != nil {
return nil, fmt.Errorf("decode: %w", err)
return nil, &ecosystemError{Service: "nexus", Op: "resolve", Status: resp.StatusCode, Err: err}
}
return &result, nil
}
@@ -132,7 +204,7 @@ func (c *nexusClient) Health(ctx context.Context) error {
if err != nil {
return err
}
setEcosystemHeaders(req, ctx, "X-Nexus-Version")
setEcosystemHeaders(req, ctx, "X-Nexus-Version", c.token)
resp, err := c.httpClient.Do(req)
if err != nil {
return err
@@ -149,6 +221,7 @@ func (c *nexusClient) Health(ctx context.Context) error {
// so attention/changes/lifecycle all go over this HTTP contract against praxisd.
type praxisClient struct {
baseURL string
token string
httpClient *http.Client
}
@@ -159,22 +232,30 @@ func newPraxisClient(url string) *praxisClient {
}
}
func (c *praxisClient) withToken(token string) *praxisClient {
c.token = token
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 {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil)
if err != nil {
return err
}
setEcosystemHeaders(req, ctx, "X-Praxis-Version")
setEcosystemHeaders(req, ctx, "X-Praxis-Version", c.token)
resp, err := c.httpClient.Do(req)
if err != nil {
return err
return &ecosystemError{Service: "praxis", Op: path, Err: err}
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return fmt.Errorf("praxis: %s", http.StatusText(resp.StatusCode))
return httpError("praxis", path, resp.StatusCode)
}
return json.NewDecoder(resp.Body).Decode(out)
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
return &ecosystemError{Service: "praxis", Op: path, Status: resp.StatusCode, Err: err}
}
return nil
}
func (c *praxisClient) ListAttention(ctx context.Context, limit int) ([]map[string]any, error) {
@@ -227,14 +308,14 @@ func (c *praxisClient) postItemAction(ctx context.Context, path, itemID string)
if err != nil {
return nil, err
}
setEcosystemHeaders(req, ctx, "X-Praxis-Version")
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, fmt.Errorf("praxis %s: %s", path, http.StatusText(resp.StatusCode))
return nil, httpError("praxis", path, resp.StatusCode)
}
var out praxisItem
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
@@ -268,14 +349,14 @@ func (c *praxisClient) Pin(ctx context.Context, itemID string, pinned bool) (*pr
if err != nil {
return nil, err
}
setEcosystemHeaders(req, ctx, "X-Praxis-Version")
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, fmt.Errorf("praxis pin: %s", http.StatusText(resp.StatusCode))
return nil, httpError("praxis", "pin", resp.StatusCode)
}
var out praxisItem
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
@@ -311,7 +392,7 @@ func wireEcosystem(cfg *config.Config) *ecosystemWiring {
// Nexus identity service
if cfg.Nexus != nil && cfg.Nexus.URL != "" {
w.nexus = newNexusClient(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")
@@ -320,6 +401,12 @@ 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")
}
log.Printf("ecosystem: hexis at %s", cfg.Hexis.URL)
} else {
log.Printf("ecosystem: hexis not configured")
@@ -327,7 +414,7 @@ func wireEcosystem(cfg *config.Config) *ecosystemWiring {
// Praxis attention service (HTTP tools API — never the DB directly)
if cfg.Praxis != nil && cfg.Praxis.URL != "" {
w.praxis = newPraxisClient(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")
+126 -3
View File
@@ -3,9 +3,11 @@ package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"strings"
"time"
hexisclient "github.com/kami/hexis/pkg/client"
"github.com/kami/maven/internal/ipc"
@@ -291,6 +293,14 @@ func (h *reactiveHandler) localFactsForEntity(ctx context.Context, entityID stri
return "я помню: " + strings.Join(parts, ", ")
}
// mergeFields overlays b onto a and returns a.
func mergeFields(a, b map[string]any) map[string]any {
for k, v := range b {
a[k] = v
}
return a
}
// recordPraxisTrace — writes a fact recording a cross-service ecosystem call.
// The fact is stored with source "praxis:trace" so the proactive loop can
// reference it and the dashboard can display recent ecosystem activity.
@@ -302,14 +312,94 @@ func (h *reactiveHandler) recordPraxisTrace(ctx context.Context, operation strin
value = operation + " " + string(b)
}
}
_, _ = h.api.WriteFact(ctx, ipc.WriteFactReq{
// Kind must be one of the store's allowed kinds ('self','env','config');
// "system" was silently rejected by the CHECK constraint, so no praxis
// trace was ever persisted.
if _, err := h.api.WriteFact(ctx, ipc.WriteFactReq{
Ts: now,
Kind: "system",
Kind: "env",
Key: "praxis:" + operation,
Value: value,
Source: "praxis:trace",
Confidence: 1.0,
})
}); err != nil {
log.Printf("ecosystem: record praxis trace %s: %v", operation, err)
}
}
// traceStatus classifies an ecosystem call for the trace record. Kept coarse
// on purpose: a trace is read to answer "did this hop work, and how long did
// it take", not to re-derive the error.
const (
traceOK = "ok"
traceFailed = "failed"
traceRefused = "refused" // the far side answered, and said no
traceAmbig = "ambiguous"
traceNotFound = "not_found"
)
// redactSubject reduces a user utterance to something safe to persist in a
// trace: its length only. Traces are diagnostics, and his words are not
// diagnostics — the correlation ID is what ties a trace to the turn.
func redactSubject(s string) string {
return fmt.Sprintf("<%d chars>", len([]rune(s)))
}
// recordEcosystemTrace writes one hop of a cross-service call: which service,
// which operation, the outcome, how long it took, and the correlation ID that
// stitches the hops together. Unlike recordPraxisTrace it is written for every
// outcome, not only success — an unrecorded failure is exactly the hop you
// need when something went wrong at 3am.
func (h *reactiveHandler) recordEcosystemTrace(ctx context.Context, service, op, status string, started time.Time, fields map[string]any) {
details := map[string]any{
"service": service,
"operation": op,
"status": status,
"duration_ms": h.now().Sub(started).Milliseconds(),
}
if id := correlationIDFromCtx(ctx); id != "" {
details["correlation_id"] = id
}
for k, v := range fields {
details[k] = v
}
value := service + ":" + op + " " + status
if b, err := json.Marshal(details); err == nil {
value = value + " " + string(b)
}
if _, err := h.api.WriteFact(ctx, ipc.WriteFactReq{
Ts: h.now(),
Kind: "env",
Key: "ecosystem:" + service + ":" + op,
Value: value,
Source: "ecosystem:trace",
Confidence: 1.0,
}); err != nil {
log.Printf("ecosystem: record trace %s:%s: %v", service, op, err)
}
}
// traceErrorFields describes an ecosystemError for a trace without leaking the
// payload: the HTTP status and the failure class, nothing else.
func traceErrorFields(err error) map[string]any {
fields := map[string]any{}
var ee *ecosystemError
if errors.As(err, &ee) {
fields["http_status"] = ee.Status
switch {
case ee.Unauthorized():
fields["class"] = "unauthorized"
case ee.ContractMismatch():
fields["class"] = "contract_mismatch"
case ee.Unreachable():
fields["class"] = "unreachable"
default:
fields["class"] = "error"
}
return fields
}
fields["class"] = "error"
return fields
}
// handleHexisAct — resolves entity references through Nexus and executes
@@ -320,10 +410,20 @@ func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decisio
return ""
}
// Every hop of this action shares one correlation ID, assigned here so
// resolution and discovery are traceable even when execution never
// happens.
if correlationIDFromCtx(ctx) == "" {
ctx = withCorrelationID(ctx, newCorrelationID())
}
// Resolve the utterance text as an entity reference through Nexus. An
// ambiguous match must stop and clarify — never guess a mutation target.
started := h.now()
entityID, displayName, ambiguous, err := h.ecosystem.resolveEntityReference(ctx, dec.Slots.Text, nil)
if err != nil {
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceFailed, started,
mergeFields(traceErrorFields(err), map[string]any{"subject": redactSubject(dec.Slots.Text)}))
// A genuine Nexus dependency failure, not "no such entity" — stop here
// and report degradation rather than silently falling through to the
// local command executor (ECOSYSTEM-SPEC.md: services degrade
@@ -331,19 +431,30 @@ func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decisio
return "экосистема недоступна, попробуй ещё раз."
}
if len(ambiguous) > 0 {
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceAmbig, started,
map[string]any{"candidates": len(ambiguous)})
return "уточни, что именно: " + strings.Join(ambiguous, ", ") + "?"
}
if entityID == "" {
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceNotFound, started,
map[string]any{"subject": redactSubject(dec.Slots.Text)})
return ""
}
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceOK, started,
map[string]any{"entity_id": entityID})
// Discover Hexis capabilities for this entity. A resolved entity with a
// genuine Hexis failure must not be treated as "no capabilities" and
// fall through to unrelated local execution.
discovered := h.now()
caps, err := h.ecosystem.discoverCapabilities(ctx, entityID)
if err != nil {
h.recordEcosystemTrace(ctx, "hexis", "capabilities", traceFailed, discovered,
mergeFields(traceErrorFields(err), map[string]any{"entity_id": entityID}))
return "экосистема недоступна, попробуй ещё раз."
}
h.recordEcosystemTrace(ctx, "hexis", "capabilities", traceOK, discovered,
map[string]any{"entity_id": entityID, "count": len(caps)})
if len(caps) == 0 {
return ""
}
@@ -388,6 +499,8 @@ func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decisio
expiry: h.now().Add(confirmTTL),
}
h.mu.Unlock()
h.recordEcosystemTrace(ctx, "hexis", "confirmation", "pending", h.now(),
map[string]any{"entity_id": entityID, "capability": matched.Name})
return "выполнить «" + matched.Name + "» для " + displayName + "? скажи «да» или «нет»."
}
@@ -398,11 +511,21 @@ func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decisio
// the correlation ID. It reports command success, never operational recovery
// (Praxis observes recovery independently).
func (h *reactiveHandler) execHexis(ctx context.Context, capID, capName, entityID, displayName string) string {
started := h.now()
causationID := correlationIDFromCtx(ctx)
correlationID, err := h.ecosystem.executeCapability(ctx, capID, entityID, nil)
traced := withCorrelationID(ctx, correlationID)
if err != nil {
log.Printf("ecosystem: hexis execute error (cor=%s): %v", correlationID, err)
h.recordEcosystemTrace(traced, "hexis", "execute", traceFailed, started,
mergeFields(traceErrorFields(err), map[string]any{
"entity_id": entityID, "capability": capName, "causation_id": causationID,
}))
return "не получилось выполнить команду для " + displayName + "."
}
h.recordEcosystemTrace(traced, "hexis", "execute", traceOK, started, map[string]any{
"entity_id": entityID, "capability": capName, "causation_id": causationID,
})
h.recordPraxisTrace(ctx, "hexis:"+capName, map[string]any{
"entity_id": entityID,
"entity_name": displayName,
+277
View File
@@ -0,0 +1,277 @@
package main
import (
"context"
"encoding/json"
"strings"
"testing"
"github.com/kami/maven/internal/store"
)
// Versioning, authentication and tracing of ecosystem calls (Vikunja #273).
func ecoTraces(t *testing.T, h *reactiveHandler) []map[string]any {
t.Helper()
facts, err := h.dataStore.RecentFacts(context.Background(), 100)
if err != nil {
t.Fatalf("read facts: %v", err)
}
var out []map[string]any
for _, f := range facts {
if f.Source != "ecosystem:trace" {
continue
}
i := strings.Index(f.Value, "{")
if i < 0 {
t.Fatalf("trace fact carries no detail object: %q", f.Value)
}
var d map[string]any
if err := json.Unmarshal([]byte(f.Value[i:]), &d); err != nil {
t.Fatalf("decode trace %q: %v", f.Value, err)
}
out = append(out, d)
}
return out
}
func findTrace(traces []map[string]any, service, op string) map[string]any {
for _, d := range traces {
if d["service"] == service && d["operation"] == op {
return d
}
}
return nil
}
// TestEcosystemHeaders_VersionRequesterAndAuth: every outgoing request carries
// the contract version, the requester, and the bearer token when configured.
func TestEcosystemHeaders_VersionRequesterAndAuth(t *testing.T) {
ctx := context.Background()
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"))
praxis := newFakePraxis(t, fixturePraxisAttentionItems())
h := ecoHandler(t, nexus, praxis, nil)
h.ecosystem.nexus = newNexusClient(nexus.URL).withToken("nexus-secret")
h.ecosystem.praxis = newPraxisClient(praxis.URL).withToken("praxis-secret")
_, _, _, err := h.ecosystem.resolveEntityReference(ctx, "muzick indexer", nil)
if err != nil {
t.Fatalf("resolve: %v", err)
}
if _, err := h.ecosystem.praxis.ListAttention(ctx, 5); err != nil {
t.Fatalf("attention: %v", err)
}
for _, tc := range []struct {
fs *fakeServer
versionHeader string
token string
}{
{nexus, "X-Nexus-Version", "nexus-secret"},
{praxis, "X-Praxis-Version", "praxis-secret"},
} {
reqs := tc.fs.Requests()
if len(reqs) == 0 {
t.Fatalf("%s: no request captured", tc.versionHeader)
}
r := reqs[0]
if got := r.Header.Get(tc.versionHeader); got != ecosystemAPIVersion {
t.Errorf("%s = %q, want %q", tc.versionHeader, got, ecosystemAPIVersion)
}
if got := r.Header.Get("X-Requested-By"); got != mavenRequester {
t.Errorf("X-Requested-By = %q, want %q", got, mavenRequester)
}
if got := r.Header.Get("Authorization"); got != "Bearer "+tc.token {
t.Errorf("Authorization = %q, want bearer %q", got, tc.token)
}
if r.Header.Get("X-Correlation-ID") == "" {
t.Errorf("%s: missing correlation ID", tc.versionHeader)
}
}
}
// TestEcosystemHeaders_NoTokenSendsNoAuth: an unconfigured token means the
// transport is trusted, not that a bogus header is sent.
func TestEcosystemHeaders_NoTokenSendsNoAuth(t *testing.T) {
ctx := context.Background()
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"))
h := ecoHandler(t, nexus, nil, nil)
if _, _, _, err := h.ecosystem.resolveEntityReference(ctx, "muzick indexer", nil); err != nil {
t.Fatalf("resolve: %v", err)
}
if got := nexus.Requests()[0].Header.Get("Authorization"); got != "" {
t.Fatalf("unauthenticated client must send no Authorization header, got %q", got)
}
}
// TestEcosystemError_ClassifiesRefusals: callers must be able to tell a
// rejected credential from a version refusal from an unreachable service
// without matching on message text.
func TestEcosystemError_ClassifiesRefusals(t *testing.T) {
ctx := context.Background()
for _, tc := range []struct {
name string
status int
check func(*ecosystemError) bool
wantCls string
}{
{"unauthorized", 401, (*ecosystemError).Unauthorized, "unauthorized"},
{"forbidden", 403, (*ecosystemError).Unauthorized, "unauthorized"},
{"contract", 426, (*ecosystemError).ContractMismatch, "contract_mismatch"},
} {
t.Run(tc.name, func(t *testing.T) {
nexus := newFakeNexus(t, fixtureNexusResolved("ent_x", "X", "service"))
nexus.SetFault(tc.status)
c := newNexusClient(nexus.URL)
_, err := c.Resolve(ctx, "x", nil)
ee, ok := err.(*ecosystemError)
if !ok {
t.Fatalf("expected *ecosystemError, got %T (%v)", err, err)
}
if ee.Service != "nexus" || ee.Status != tc.status {
t.Fatalf("unexpected typed error %+v", ee)
}
if !tc.check(ee) {
t.Fatalf("%s not classified: %+v", tc.name, ee)
}
if got := traceErrorFields(err)["class"]; got != tc.wantCls {
t.Fatalf("trace class = %v, want %s", got, tc.wantCls)
}
})
}
}
func TestEcosystemError_UnreachableHasNoStatus(t *testing.T) {
c := newNexusClient("http://127.0.0.1:1")
_, err := c.Resolve(context.Background(), "x", nil)
ee, ok := err.(*ecosystemError)
if !ok {
t.Fatalf("expected *ecosystemError, got %T", err)
}
if !ee.Unreachable() || ee.Unauthorized() || ee.ContractMismatch() {
t.Fatalf("a refused connection must classify as unreachable only: %+v", ee)
}
}
// TestEcosystemTrace_SuccessfulActionTracesEveryHop: resolution, discovery and
// execution each leave a record sharing one correlation chain, with timing and
// status, and execution carries the causation link back to the resolve.
func TestEcosystemTrace_SuccessfulActionTracesEveryHop(t *testing.T) {
ctx := context.Background()
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"))
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
h := ecoHandler(t, nexus, nil, hexis)
if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !strings.Contains(reply, "выполнена") {
t.Fatalf("setup: expected success, got %q", reply)
}
traces := ecoTraces(t, h)
for _, want := range [][2]string{{"nexus", "resolve"}, {"hexis", "capabilities"}, {"hexis", "execute"}} {
d := findTrace(traces, want[0], want[1])
if d == nil {
t.Fatalf("missing trace for %s %s, got %+v", want[0], want[1], traces)
}
if d["status"] != traceOK {
t.Errorf("%s %s status = %v, want ok", want[0], want[1], d["status"])
}
if _, ok := d["duration_ms"]; !ok {
t.Errorf("%s %s trace has no timing", want[0], want[1])
}
if d["correlation_id"] == nil || d["correlation_id"] == "" {
t.Errorf("%s %s trace has no correlation id", want[0], want[1])
}
}
exec := findTrace(traces, "hexis", "execute")
if exec["causation_id"] == nil || exec["causation_id"] == "" {
t.Error("execute trace must carry the causation id of the turn that caused it")
}
if exec["correlation_id"] == exec["causation_id"] {
t.Error("execute correlation and causation must be distinguishable")
}
}
// TestEcosystemTrace_FailuresAreTracedToo: the whole point of the change —
// a failed hop is exactly the one worth having recorded.
func TestEcosystemTrace_FailuresAreTracedToo(t *testing.T) {
ctx := context.Background()
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"))
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
h := ecoHandler(t, nexus, nil, hexis)
nexus.SetFault(401)
_ = h.handleHexisAct(ctx, actDec("muzick indexer"))
d := findTrace(ecoTraces(t, h), "nexus", "resolve")
if d == nil {
t.Fatal("a failed resolve must still be traced")
}
if d["status"] != traceFailed {
t.Errorf("status = %v, want failed", d["status"])
}
if d["class"] != "unauthorized" {
t.Errorf("class = %v, want unauthorized", d["class"])
}
if d["http_status"] != float64(401) {
t.Errorf("http_status = %v, want 401", d["http_status"])
}
}
// TestEcosystemTrace_RedactsTheUtterance: traces are diagnostics, his words
// are not. The subject must never be persisted verbatim.
func TestEcosystemTrace_RedactsTheUtterance(t *testing.T) {
ctx := context.Background()
nexus := newFakeNexus(t, fixtureNexusNotFound())
h := ecoHandler(t, nexus, nil, nil)
_ = h.handleHexisAct(ctx, actDec("перезапусти кофемашину"))
facts, err := h.dataStore.RecentFacts(ctx, 100)
if err != nil {
t.Fatalf("read facts: %v", err)
}
var traced []store.Fact
for _, f := range facts {
if f.Source == "ecosystem:trace" {
traced = append(traced, f)
}
if strings.Contains(f.Value, "кофемашину") && f.Source == "ecosystem:trace" {
t.Fatalf("trace leaked the utterance: %q", f.Value)
}
}
if len(traced) == 0 {
t.Fatal("expected a not_found resolve trace")
}
d := findTrace(ecoTraces(t, h), "nexus", "resolve")
if d["status"] != traceNotFound {
t.Errorf("status = %v, want not_found", d["status"])
}
if d["subject"] != redactSubject("перезапусти кофемашину") {
t.Errorf("subject = %v, want a redacted length", d["subject"])
}
}
// TestEcosystemTrace_AmbiguityAndConfirmationAreRecorded: the two moments
// where Maven deliberately does not act still leave a trail.
func TestEcosystemTrace_AmbiguityAndConfirmationAreRecorded(t *testing.T) {
ctx := context.Background()
ambig := newFakeNexus(t, fixtureNexusAmbiguous(
map[string]string{"entity_id": "ent_a", "display_name": "Muzick indexer"},
map[string]string{"entity_id": "ent_b", "display_name": "Muzick web"},
))
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
h := ecoHandler(t, ambig, nil, hexis)
_ = h.handleHexisAct(ctx, actDec("muzick"))
if d := findTrace(ecoTraces(t, h), "nexus", "resolve"); d == nil || d["status"] != traceAmbig {
t.Fatalf("ambiguous resolve must be traced as such, got %+v", d)
}
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"))
mutating := fixtureHexisCapabilities(map[string]any{"id": "cap_restart", "name": "restart", "read_only": false})
h2 := ecoHandler(t, nexus, nil, newFakeHexis(t, mutating, fixtureHexisExecuted("exec_1", "succeeded")))
_ = h2.handleHexisAct(ctx, actDec("restart"))
if d := findTrace(ecoTraces(t, h2), "hexis", "confirmation"); d == nil || d["status"] != "pending" {
t.Fatalf("a parked confirmation must be traced, got %+v", d)
}
}
+12
View File
@@ -461,18 +461,30 @@ func (c *Config) MCPServers() []mcp.ServerConfig {
type PraxisConfig struct {
// URL — the Praxis HTTP API base URL (e.g. "http://localhost:9742").
URL string `json:"url,omitempty"`
// Token — the shared bearer token sent on every request. Empty ⇒ calls
// go out unauthenticated, which is only appropriate on a loopback or
// unix-socket transport. Supports ${VAR} expansion, so the secret lives
// in deploy/telegram.env, not in the committed config.
Token string `json:"token,omitempty"`
}
// NexusConfig — connection to the Nexus identity service.
type NexusConfig struct {
// URL — the Nexus HTTP API base URL (e.g. "http://localhost:9740").
URL string `json:"url,omitempty"`
// Token — shared bearer token; see PraxisConfig.Token.
Token string `json:"token,omitempty"`
}
// HexisConfig — connection to the Hexis capability execution service.
type HexisConfig struct {
// URL — the Hexis HTTP API base URL (e.g. "http://localhost:9741").
URL string `json:"url,omitempty"`
// Token — shared bearer token; see PraxisConfig.Token.
Token string `json:"token,omitempty"`
}
// RoutineConfig — one scheduled routine. Cron is a standard 5-field expression