diff --git a/cmd/mavend/ecosystem_acts.go b/cmd/mavend/ecosystem_acts.go index a2bb47d..7ed0be7 100644 --- a/cmd/mavend/ecosystem_acts.go +++ b/cmd/mavend/ecosystem_acts.go @@ -14,6 +14,19 @@ import ( "github.com/kami/maven/internal/store" ) +// The three services, spelled the way she says them out loud. A service that is +// down or refusing has to be named: they degrade independently, so "не +// отвечает" on its own tells him nothing he can act on, and each call site +// already knows which one it was talking to — it records the same name in the +// trace (Vikunja #521). +const ( + serviceNexus = "Nexus" + serviceHexis = "Hexis" +) + +// serviceVars — the one-key map the eco_down and eco_denied lines take. +func serviceVars(name string) map[string]string { return map[string]string{"name": name} } + // 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 @@ -158,6 +171,11 @@ func (listAttentionCapability) handle(ctx context.Context, h *reactiveHandler, p importance, _ := item["importance"].(float64) rule, _ := item["rule"].(string) s := title + if s == "" { + // An item Praxis returned without a title is not an item she can + // read out. Counting it would put an empty slot in the list. + continue + } if importance > 0 { s += fmt.Sprintf(" (важность %d", int(importance)) if rule != "" { @@ -176,6 +194,12 @@ func (listAttentionCapability) handle(ctx context.Context, h *reactiveHandler, p } } } + if len(parts) == 0 { + // Praxis returned items and not one of them could be said. "ничего не + // требует внимания" is the honest answer; the list line would render as + // its own label and a colon (Vikunja #521). + return phraser.A(phraser.AttentionNone, nil) + } return phraser.A(phraser.AttentionList, map[string]string{"items": strings.Join(parts, "; ")}) } @@ -202,9 +226,19 @@ func (listChangesCapability) handle(ctx context.Context, h *reactiveHandler, px var parts []string for _, c := range changes { title, _ := c["title"].(string) + if title == "" { + continue + } typ, _ := c["change_type"].(string) + if typ == "" { + parts = append(parts, title) + continue + } parts = append(parts, fmt.Sprintf("%s (%s)", title, typ)) } + if len(parts) == 0 { + return phraser.A(phraser.ChangesNone, nil) + } return phraser.A(phraser.ChangesList, map[string]string{"items": strings.Join(parts, "; ")}) } @@ -249,9 +283,9 @@ func (entityAttentionCapability) handle(ctx context.Context, h *reactiveHandler, h.recordEcosystemTrace(ctx, "nexus", "resolve", traceStatusForError(err), started, mergeFields(traceErrorFields(err), map[string]any{"subject": redactSubject(subject)})) if unauthorizedEcosystemError(err) { - return phraser.A(phraser.EcoDenied, nil) + return phraser.A(phraser.EcoDenied, serviceVars(serviceNexus)) } - return phraser.A(phraser.EcoDown, nil) + return phraser.A(phraser.EcoDown, serviceVars(serviceNexus)) } if len(ambiguous) > 0 { return phraser.A(phraser.EcoAmbiguous, map[string]string{"items": strings.Join(ambiguous, ", ")}) @@ -521,13 +555,13 @@ func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decisio h.recordEcosystemTrace(ctx, "nexus", "resolve", traceStatusForError(err), started, mergeFields(traceErrorFields(err), map[string]any{"subject": redactSubject(dec.Slots.Text)})) if unauthorizedEcosystemError(err) { - return phraser.A(phraser.EcoDenied, nil) + return phraser.A(phraser.EcoDenied, serviceVars(serviceNexus)) } // 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 phraser.A(phraser.EcoDown, nil) + return phraser.A(phraser.EcoDown, serviceVars(serviceNexus)) } if len(ambiguous) > 0 { h.recordEcosystemTrace(ctx, "nexus", "resolve", traceAmbig, started, @@ -551,9 +585,9 @@ func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decisio h.recordEcosystemTrace(ctx, "hexis", "capabilities", traceStatusForError(err), discovered, mergeFields(traceErrorFields(err), map[string]any{"entity_id": entityID})) if unauthorizedEcosystemError(err) { - return phraser.A(phraser.EcoDenied, nil) + return phraser.A(phraser.EcoDenied, serviceVars(serviceHexis)) } - return phraser.A(phraser.EcoDown, nil) + return phraser.A(phraser.EcoDown, serviceVars(serviceHexis)) } h.recordEcosystemTrace(ctx, "hexis", "capabilities", traceOK, discovered, map[string]any{"entity_id": entityID, "count": len(caps)}) @@ -603,7 +637,7 @@ func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decisio h.mu.Unlock() h.recordEcosystemTrace(ctx, "hexis", "confirmation", tracePending, started, map[string]any{"entity_id": entityID, "capability": matched.Name}) - return phraser.A(phraser.ActConfirmEntity, map[string]string{"name": matched.Name, "entity": displayName}) + return phraser.A(phraser.ActConfirmEntity, map[string]string{"name": matched.Name, "name_entity": displayName}) } return h.execHexis(ctx, matched.ID, matched.Name, entityID, displayName) diff --git a/cmd/mavend/ecosystem_degraded_test.go b/cmd/mavend/ecosystem_degraded_test.go index 383959e..ac9b9fc 100644 --- a/cmd/mavend/ecosystem_degraded_test.go +++ b/cmd/mavend/ecosystem_degraded_test.go @@ -96,7 +96,7 @@ func TestEcosystem_OutagesLeaveNoSharedFailureState(t *testing.T) { // A Nexus outage during a Hexis act writes a failure trace, and a shared // store is the one thing the Praxis path could inherit it through. nexus.SetFault(503) - if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); strings.Contains(reply, "выполнена") { + if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); actRan(reply) { t.Fatalf("nexus outage must not report success, got %q", reply) } if len(tracesFor(t, h, "nexus", "resolve")) == 0 { @@ -117,7 +117,7 @@ func TestEcosystem_OutagesLeaveNoSharedFailureState(t *testing.T) { if reply := h.handlePraxisAct(ctx, praxisActDec("list_attention")); strings.Contains(reply, "disk") { t.Fatalf("praxis outage must not serve content, got %q", reply) } - if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !strings.Contains(reply, "выполнена") { + if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !actRan(reply) { t.Fatalf("a praxis outage must not block the hexis path, got %q", reply) } } @@ -154,7 +154,7 @@ func TestEcosystem_ResolvedWithoutEntityFailsClosed(t *testing.T) { if reply == "" { t.Fatal("a resolve with no entity must degrade, not fall through to local execution") } - if strings.Contains(reply, "выполнена") { + if actRan(reply) { t.Fatalf("a resolve with no entity must not report success, got %q", reply) } if hexis.Count("", "/api/v1") != 0 { @@ -212,7 +212,7 @@ func TestEcosystem_MalformedNexusResponseFailsClosed(t *testing.T) { nexus.SetBody(`{"status":"resolved","entity":`) reply := h.handleHexisAct(ctx, actDec("muzick indexer")) - if reply == "" || strings.Contains(reply, "выполнена") { + if reply == "" || actRan(reply) { t.Fatalf("malformed nexus body must degrade, got %q", reply) } if hexis.Count("", "/api/v1") != 0 { @@ -232,7 +232,7 @@ func TestEcosystem_UnknownContractFieldsTolerated(t *testing.T) { nexus := newFakeNexus(t, body) 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, "выполнена") { + if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !actRan(reply) { t.Fatalf("%s contract shape must still resolve and execute, got %q", name, reply) } }) @@ -250,7 +250,7 @@ func TestEcosystem_CancelledContextDegrades(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond) defer cancel() reply := h.handleHexisAct(ctx, actDec("muzick indexer")) - if reply == "" || strings.Contains(reply, "выполнена") { + if reply == "" || actRan(reply) { t.Fatalf("cancelled resolve must degrade, got %q", reply) } if hexis.Count("", "/api/v1") != 0 { @@ -268,7 +268,7 @@ func TestEcosystem_ExecutionFailureIsNotSuccess(t *testing.T) { h := ecoHandler(t, nexus, nil, hexis) reply := h.handleHexisAct(ctx, actDec("muzick indexer")) - if strings.Contains(reply, "выполнена") { + if actRan(reply) { t.Fatalf("failed execution must not read as success, got %q", reply) } if reply == "" { @@ -291,7 +291,7 @@ func TestEcosystem_SuccessfulActionWritesATrace(t *testing.T) { 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, "выполнена") { + if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !actRan(reply) { t.Fatalf("setup: expected success, got %q", reply) } exec := tracesFor(t, h, "hexis", "execute") @@ -313,7 +313,7 @@ func TestEcosystem_TracesStayOutOfFacts(t *testing.T) { 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, "выполнена") { + if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !actRan(reply) { t.Fatalf("setup: expected success, got %q", reply) } if len(traces(t, h)) == 0 { @@ -434,7 +434,7 @@ func TestEcosystem_TotalOutageSaysSoForEveryPath(t *testing.T) { if reply == "" { t.Errorf("%s: total outage must not answer with silence", name) } - if strings.Contains(reply, "выполнена") { + if actRan(reply) { t.Errorf("%s: total outage must not claim success: %q", name, reply) } } diff --git a/cmd/mavend/ecosystem_harness_test.go b/cmd/mavend/ecosystem_harness_test.go index 521d220..8b8ce5d 100644 --- a/cmd/mavend/ecosystem_harness_test.go +++ b/cmd/mavend/ecosystem_harness_test.go @@ -105,13 +105,13 @@ func TestFakeNexus_FaultInjectionThenRecovery(t *testing.T) { nexus.SetFault(503) reply := h.handleHexisAct(ctx, actDec("muzick indexer")) - if strings.Contains(reply, "выполнена") { + if actRan(reply) { t.Fatalf("nexus outage must not report success, got %q", reply) } nexus.SetFault(0) reply = h.handleHexisAct(ctx, actDec("muzick indexer")) - if !strings.Contains(reply, "выполнена") { + if !actRan(reply) { t.Fatalf("expected success once nexus recovers, got %q", reply) } } diff --git a/cmd/mavend/ecosystem_test.go b/cmd/mavend/ecosystem_test.go index 4fc557f..9296e1f 100644 --- a/cmd/mavend/ecosystem_test.go +++ b/cmd/mavend/ecosystem_test.go @@ -11,6 +11,7 @@ import ( hexisclient "github.com/kami/hexis/pkg/client" "github.com/kami/maven/internal/ipc" + "github.com/kami/maven/internal/phraser" "github.com/kami/maven/internal/router" ) @@ -85,7 +86,7 @@ func TestHexisMutatingRequiresConfirm(t *testing.T) { // The follow-up "да" turn executes exactly the parked capability. confirmReply, handled := h.resolveConfirm(ctx, "да") - if !handled || !strings.Contains(confirmReply, "выполнена") { + if !handled || !actRan(confirmReply) { t.Fatalf("confirm should execute, got handled=%v reply=%q", handled, confirmReply) } if !*executed { @@ -125,7 +126,7 @@ func TestHexisReadOnlyExecutesImmediately(t *testing.T) { if h.pendingHexis != nil { t.Fatal("read-only cap should not park a confirmation") } - if !strings.Contains(reply, "выполнена") { + if !actRan(reply) { t.Fatalf("unexpected reply %q", reply) } } @@ -186,7 +187,7 @@ func TestHexisNexusErrorFailsClosed(t *testing.T) { if reply == "" { t.Fatal("nexus dependency failure must not fall through with an empty reply") } - if strings.Contains(reply, "выполнена") { + if actRan(reply) { t.Fatalf("nexus dependency failure must not report success, got %q", reply) } } @@ -219,7 +220,7 @@ func TestHexisUnavailableFailsClosed(t *testing.T) { if reply == "" { t.Fatal("hexis dependency failure must not fall through with an empty reply") } - if strings.Contains(reply, "выполнена") { + if actRan(reply) { t.Fatalf("hexis dependency failure must not report success, got %q", reply) } } @@ -241,3 +242,14 @@ func TestHexisNotFoundStillFallsThrough(t *testing.T) { t.Fatal("not_found resolution must never execute a hexis capability") } } + +// actRan — the reply is the line she says when a capability ran against an +// entity. The tests used to look for the substring "выполнена", which was a +// literal out of the act file: the review reworded that line to "готово: {name}" +// and seventeen assertions went with it (Vikunja #521). +func actRan(reply string) bool { + return phraser.IsA(phraser.ActDoneEntity, map[string]string{"name": muzickIndexer}, reply) +} + +// muzickIndexer — the display name every ecosystem fixture resolves to. +const muzickIndexer = "Muzick indexer" diff --git a/cmd/mavend/ecosystem_trace_test.go b/cmd/mavend/ecosystem_trace_test.go index 86d3b1c..7345481 100644 --- a/cmd/mavend/ecosystem_trace_test.go +++ b/cmd/mavend/ecosystem_trace_test.go @@ -142,7 +142,7 @@ func TestEcosystemTrace_SuccessfulActionTracesEveryHop(t *testing.T) { 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, "выполнена") { + if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !actRan(reply) { t.Fatalf("setup: expected success, got %q", reply) }