927e46bca3
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.
537 lines
19 KiB
Go
537 lines
19 KiB
Go
package main
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"log"
|
||
"strings"
|
||
"time"
|
||
|
||
hexisclient "github.com/kami/hexis/pkg/client"
|
||
"github.com/kami/maven/internal/ipc"
|
||
"github.com/kami/maven/internal/router"
|
||
)
|
||
|
||
// praxisCapability is one arm of the Praxis act dispatch. This is an interface
|
||
// rather than a map[string]func because each arm carries its own state: the
|
||
// verb aliases it answers to, the trace name it records, and its own reply
|
||
// formatting. The dispatch grows an arm per Praxis capability, so a new one is
|
||
// added to praxisCapabilities below and nothing else changes.
|
||
type praxisCapability interface {
|
||
// aliases are the verbs (router fn slots, EN and RU) this capability answers to.
|
||
aliases() []string
|
||
// handle runs the capability and returns the user-facing reply.
|
||
handle(ctx context.Context, h *reactiveHandler, px *praxisClient, dec router.Decision) string
|
||
}
|
||
|
||
// praxisCapabilities is the registry handlePraxisAct consults, in order.
|
||
var praxisCapabilities = []praxisCapability{
|
||
listAttentionCapability{},
|
||
praxisItemAction{
|
||
verbs: []string{"acknowledge_item", "принято", "понял", "поняла"},
|
||
ask: "какой пункт отметить принятым?",
|
||
op: "acknowledge",
|
||
failure: "не получилось отметить принятым.",
|
||
success: "принято.",
|
||
call: func(ctx context.Context, px *praxisClient, id string) error {
|
||
_, err := px.Acknowledge(ctx, id)
|
||
return err
|
||
},
|
||
},
|
||
praxisItemAction{
|
||
verbs: []string{"resolve_item", "сделано", "готово", "решено"},
|
||
ask: "какой пункт отметить сделанным?",
|
||
op: "resolve",
|
||
failure: "не получилось отметить сделанным.",
|
||
success: "отмечено как сделано.",
|
||
call: func(ctx context.Context, px *praxisClient, id string) error {
|
||
_, err := px.Resolve(ctx, id)
|
||
return err
|
||
},
|
||
},
|
||
praxisItemAction{
|
||
verbs: []string{"ignore_item", "игнорировать", "неважно"},
|
||
ask: "какой пункт игнорировать?",
|
||
op: "ignore",
|
||
failure: "не получилось проигнорировать.",
|
||
success: "проигнорировано.",
|
||
call: func(ctx context.Context, px *praxisClient, id string) error {
|
||
_, err := px.Ignore(ctx, id)
|
||
return err
|
||
},
|
||
},
|
||
praxisItemAction{
|
||
verbs: []string{"pin_item", "закрепить"},
|
||
ask: "какой пункт закрепить?",
|
||
op: "pin",
|
||
failure: "не получилось закрепить.",
|
||
success: "закреплено.",
|
||
call: func(ctx context.Context, px *praxisClient, id string) error {
|
||
_, err := px.Pin(ctx, id, true)
|
||
return err
|
||
},
|
||
},
|
||
listChangesCapability{},
|
||
entityAttentionCapability{},
|
||
}
|
||
|
||
// handlePraxisAct — dispatches ecosystem tool acts through the Praxis tools API.
|
||
// Returns "" when the act is not a Praxis verb (the caller falls through to the
|
||
// system command executor). Returns a reply string otherwise.
|
||
func (h *reactiveHandler) handlePraxisAct(ctx context.Context, dec router.Decision) string {
|
||
if h.ecosystem == nil || h.ecosystem.praxis == nil {
|
||
return ""
|
||
}
|
||
px := h.ecosystem.praxis
|
||
for _, capability := range praxisCapabilities {
|
||
for _, alias := range capability.aliases() {
|
||
if alias == dec.Slots.Fn {
|
||
return capability.handle(ctx, h, px, dec)
|
||
}
|
||
}
|
||
}
|
||
// Not a Praxis verb — let the caller fall through.
|
||
return ""
|
||
}
|
||
|
||
// praxisItemAction is the shared shape of the item-lifecycle capabilities: take
|
||
// an item id from the value slot, call one Praxis endpoint, trace the result.
|
||
type praxisItemAction struct {
|
||
verbs []string
|
||
ask string // reply when no item id was given
|
||
op string // trace + log name of the operation
|
||
failure string // reply when the Praxis call errors
|
||
success string
|
||
call func(ctx context.Context, px *praxisClient, id string) error
|
||
}
|
||
|
||
func (a praxisItemAction) aliases() []string { return a.verbs }
|
||
|
||
func (a praxisItemAction) handle(ctx context.Context, h *reactiveHandler, px *praxisClient, dec router.Decision) string {
|
||
id := dec.Slots.Value
|
||
if id == "" {
|
||
return a.ask
|
||
}
|
||
if err := a.call(ctx, px, id); err != nil {
|
||
log.Printf("ecosystem: praxis %s %s: %v", a.op, id, err)
|
||
return a.failure
|
||
}
|
||
h.recordPraxisTrace(ctx, a.op, map[string]any{"item_id": id})
|
||
return a.success
|
||
}
|
||
|
||
// listAttentionCapability reads the attention digest and surfaces every item it speaks.
|
||
type listAttentionCapability struct{}
|
||
|
||
func (listAttentionCapability) aliases() []string {
|
||
return []string{"list_attention", "attention", "внимание", "что требует внимания", "что нового"}
|
||
}
|
||
|
||
func (listAttentionCapability) handle(ctx context.Context, h *reactiveHandler, px *praxisClient, _ router.Decision) string {
|
||
items, err := px.ListAttention(ctx, 20)
|
||
if err != nil {
|
||
log.Printf("ecosystem: praxis attention: %v", err)
|
||
return "не могу сейчас узнать, что требует внимания."
|
||
}
|
||
if len(items) == 0 {
|
||
return "ничего не требует внимания."
|
||
}
|
||
h.recordPraxisTrace(ctx, "list_attention", map[string]any{"count": len(items)})
|
||
var parts []string
|
||
for _, item := range items {
|
||
title, _ := item["title"].(string)
|
||
// importance arrives as JSON number ⇒ float64 over the HTTP contract.
|
||
importance, _ := item["importance"].(float64)
|
||
rule, _ := item["rule"].(string)
|
||
s := title
|
||
if importance > 0 {
|
||
s += fmt.Sprintf(" (важность %d", int(importance))
|
||
if rule != "" {
|
||
s += ": " + rule
|
||
}
|
||
s += ")"
|
||
}
|
||
parts = append(parts, s)
|
||
|
||
// Speaking an item surfaces it, it does not acknowledge it
|
||
// (ECOSYSTEM-SPEC.md §2.3: surfaced != acknowledged). Best-effort:
|
||
// a failed surface call must not block delivering the digest.
|
||
if id, ok := item["id"].(string); ok && id != "" {
|
||
if _, err := px.Surface(ctx, id); err != nil {
|
||
log.Printf("ecosystem: praxis surface %s: %v", id, err)
|
||
}
|
||
}
|
||
}
|
||
return "требует внимания: " + strings.Join(parts, "; ")
|
||
}
|
||
|
||
// listChangesCapability reads the recent-changes feed.
|
||
type listChangesCapability struct{}
|
||
|
||
func (listChangesCapability) aliases() []string {
|
||
return []string{"list_changes", "changes", "изменения", "что изменилось"}
|
||
}
|
||
|
||
func (listChangesCapability) handle(ctx context.Context, h *reactiveHandler, px *praxisClient, _ router.Decision) string {
|
||
changes, err := px.ListChanges(ctx, 20)
|
||
if err != nil {
|
||
log.Printf("ecosystem: praxis changes: %v", err)
|
||
return "не могу сейчас узнать об изменениях."
|
||
}
|
||
if len(changes) == 0 {
|
||
return "нет изменений."
|
||
}
|
||
h.recordPraxisTrace(ctx, "list_changes", map[string]any{"count": len(changes)})
|
||
var parts []string
|
||
for _, c := range changes {
|
||
title, _ := c["title"].(string)
|
||
typ, _ := c["change_type"].(string)
|
||
parts = append(parts, fmt.Sprintf("%s (%s)", title, typ))
|
||
}
|
||
return "изменения: " + strings.Join(parts, "; ")
|
||
}
|
||
|
||
// entityAttentionCapability answers "what's going on with X" by resolving X to
|
||
// a canonical Nexus entity and asking Praxis for that entity's attention items
|
||
// (Vikunja #272). Unlike listAttentionCapability it is scoped: the entity_id
|
||
// travels to Praxis as a query parameter instead of Maven filtering an unscoped
|
||
// list client-side, which is what makes the ref canonical end to end.
|
||
//
|
||
// It also folds in what Maven herself knows about the same entity — facts the
|
||
// enrichment worker has already resolved to that entity_id — so one question
|
||
// gets one answer across both stores.
|
||
type entityAttentionCapability struct{}
|
||
|
||
func (entityAttentionCapability) aliases() []string {
|
||
return []string{"entity_attention", "что с", "как дела у", "статус"}
|
||
}
|
||
|
||
func (entityAttentionCapability) handle(ctx context.Context, h *reactiveHandler, px *praxisClient, dec router.Decision) string {
|
||
subject := dec.Slots.Value
|
||
if subject == "" {
|
||
subject = dec.Slots.Text
|
||
}
|
||
if subject == "" {
|
||
return "про что именно спросить?"
|
||
}
|
||
if h.ecosystem == nil || h.ecosystem.nexus == nil {
|
||
// Without Nexus there is no canonical ref to scope by. Say so rather
|
||
// than quietly answering about something else.
|
||
return "не могу связать это с сущностью — Nexus не настроен."
|
||
}
|
||
|
||
entityID, displayName, ambiguous, err := h.ecosystem.resolveEntityReference(ctx, subject, nil)
|
||
if err != nil {
|
||
log.Printf("ecosystem: entity attention resolve %q: %v", subject, err)
|
||
return "экосистема недоступна, попробуй ещё раз."
|
||
}
|
||
if len(ambiguous) > 0 {
|
||
return "уточни, что именно: " + strings.Join(ambiguous, ", ") + "?"
|
||
}
|
||
if entityID == "" {
|
||
return "не знаю такой сущности."
|
||
}
|
||
if displayName == "" {
|
||
displayName = subject
|
||
}
|
||
|
||
items, err := px.ListAttentionForEntity(ctx, entityID, 20)
|
||
if err != nil {
|
||
log.Printf("ecosystem: praxis attention for %s: %v", entityID, err)
|
||
return "не могу сейчас узнать, что требует внимания по «" + displayName + "»."
|
||
}
|
||
h.recordPraxisTrace(ctx, "entity_attention", map[string]any{
|
||
"entity_id": entityID, "count": len(items),
|
||
})
|
||
|
||
var parts []string
|
||
for _, item := range items {
|
||
title, _ := item["title"].(string)
|
||
if title == "" {
|
||
continue
|
||
}
|
||
parts = append(parts, title)
|
||
// Same surfaced != acknowledged rule as the unscoped digest.
|
||
if id, ok := item["id"].(string); ok && id != "" {
|
||
if _, err := px.Surface(ctx, id); err != nil {
|
||
log.Printf("ecosystem: praxis surface %s: %v", id, err)
|
||
}
|
||
}
|
||
}
|
||
if known := h.localFactsForEntity(ctx, entityID); known != "" {
|
||
parts = append(parts, known)
|
||
}
|
||
if len(parts) == 0 {
|
||
return "по «" + displayName + "» ничего нет."
|
||
}
|
||
return "по «" + displayName + "»: " + strings.Join(parts, "; ")
|
||
}
|
||
|
||
// localFactsForEntity summarises Maven's own facts already resolved to this
|
||
// canonical entity. Empty when the store is unavailable or nothing matched —
|
||
// entity-scoped memory is an enrichment of the answer, never a precondition.
|
||
func (h *reactiveHandler) localFactsForEntity(ctx context.Context, entityID string) string {
|
||
if h.dataStore == nil || entityID == "" {
|
||
return ""
|
||
}
|
||
facts, err := h.dataStore.FactsByEntity(ctx, entityID, 3)
|
||
if err != nil {
|
||
log.Printf("ecosystem: facts by entity %s: %v", entityID, err)
|
||
return ""
|
||
}
|
||
var parts []string
|
||
for _, f := range facts {
|
||
if f.Value != "" {
|
||
parts = append(parts, f.Value)
|
||
}
|
||
}
|
||
if len(parts) == 0 {
|
||
return ""
|
||
}
|
||
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.
|
||
func (h *reactiveHandler) recordPraxisTrace(ctx context.Context, operation string, details map[string]any) {
|
||
now := h.now()
|
||
value := operation
|
||
if len(details) > 0 {
|
||
if b, err := json.Marshal(details); err == nil {
|
||
value = operation + " " + string(b)
|
||
}
|
||
}
|
||
// 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: "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
|
||
// matching capabilities through Hexis. Returns a reply string when handled,
|
||
// or "" to fall through to the system command executor.
|
||
func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decision) string {
|
||
if h.ecosystem == nil {
|
||
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
|
||
// independently, never a silent all-clear).
|
||
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 ""
|
||
}
|
||
|
||
// Match the user's verb to a capability by name/description. Collect all
|
||
// matches: more than one is itself ambiguous, so we ask rather than pick
|
||
// the first (ecosystem invariant: no arbitrary target for mutation).
|
||
verb := dec.Slots.Fn
|
||
if verb == "" {
|
||
verb = dec.Slots.Text
|
||
}
|
||
verbLower := strings.ToLower(verb)
|
||
|
||
var matches []*hexisclient.Capability
|
||
for i, c := range caps {
|
||
if strings.Contains(strings.ToLower(c.Name), verbLower) ||
|
||
(c.Description != "" && strings.Contains(strings.ToLower(c.Description), verbLower)) {
|
||
matches = append(matches, &caps[i])
|
||
}
|
||
}
|
||
if len(matches) == 0 {
|
||
return ""
|
||
}
|
||
if len(matches) > 1 {
|
||
var names []string
|
||
for _, m := range matches {
|
||
names = append(names, m.Name)
|
||
}
|
||
return "какую команду для " + displayName + ": " + strings.Join(names, ", ") + "?"
|
||
}
|
||
matched := matches[0]
|
||
|
||
// Read-only capabilities run immediately; mutating ones are parked for an
|
||
// explicit spoken confirm bound to this capability + target.
|
||
if !matched.ReadOnly {
|
||
h.mu.Lock()
|
||
h.pendingHexis = &pendingHexisExec{
|
||
capabilityID: matched.ID,
|
||
capName: matched.Name,
|
||
entityID: entityID,
|
||
displayName: displayName,
|
||
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 + "? скажи «да» или «нет»."
|
||
}
|
||
|
||
return h.execHexis(ctx, matched.ID, matched.Name, entityID, displayName)
|
||
}
|
||
|
||
// execHexis runs a resolved capability and records a cross-service trace with
|
||
// 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,
|
||
"capability": capName,
|
||
"correlation_id": correlationID,
|
||
})
|
||
return "команда выполнена для " + displayName + "."
|
||
}
|