6c92f85d10
Bring the Nexus/Praxis/Hexis integration in line with MAVEN_ECOSYSTEM_ARCHITECTURE.md: - Praxis over HTTP: drop the in-process praxis.db open (praxisstore/ praxistools) and call praxisd's /api/v1/tools/* API via a new praxisClient. Honors the "no component reads another's DB" invariant (AC#12). PraxisConfig.DBPath -> URL. - Hexis confirmation gate: mutating capabilities (ReadOnly=false) now park a bound pendingHexis confirmation and require a spoken "да" before executing; read-only run immediately (AC#7, no auto attention->action). - Capability safety: >1 verb match is ambiguous -> ask instead of firing the first; ambiguous Nexus resolution asks for clarification (AC#2). - Correlation IDs on Hexis execute, recorded in the cross-service trace. - Bug: importance arrives as JSON float64 over HTTP, not int. - Tests: confirm-gate, decline, read-only, and ambiguity paths. Build: vendor/ bakes in the hexis client (replace-directed at a sibling repo outside the Docker context); Dockerfile builds from vendor and no longer `go mod download`s the unreachable replace paths. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
143 lines
4.8 KiB
Go
143 lines
4.8 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
hexisclient "github.com/kami/hexis/pkg/client"
|
|
|
|
"github.com/kami/maven/internal/ipc"
|
|
"github.com/kami/maven/internal/router"
|
|
)
|
|
|
|
// stubEcosystem wires nexus+hexis clients at the given base URLs.
|
|
func stubEcosystem(nexusURL, hexisURL string) *ecosystemWiring {
|
|
return &ecosystemWiring{
|
|
nexus: newNexusClient(nexusURL),
|
|
hexis: hexisclient.New(hexisURL),
|
|
}
|
|
}
|
|
|
|
// newHexisTestHandler builds a reactiveHandler backed by fake nexus+hexis
|
|
// servers. resolveBody is returned verbatim from nexus /resolve; caps is the
|
|
// capability list; executed records whether Hexis /execute was called.
|
|
func newHexisTestHandler(t *testing.T, resolveBody string, caps string) (*reactiveHandler, *bool) {
|
|
t.Helper()
|
|
executed := new(bool)
|
|
|
|
nexus := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Write([]byte(resolveBody))
|
|
}))
|
|
t.Cleanup(nexus.Close)
|
|
|
|
hexis := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
switch {
|
|
case strings.HasPrefix(r.URL.Path, "/api/v1/capabilities"):
|
|
w.Write([]byte(caps))
|
|
case r.URL.Path == "/api/v1/execute":
|
|
*executed = true
|
|
w.Write([]byte(`{"id":"exec_1","status":"succeeded"}`))
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
t.Cleanup(hexis.Close)
|
|
|
|
st := newTestStore(t)
|
|
now := time.Now()
|
|
return &reactiveHandler{
|
|
api: ipc.NewStoreAPI(st),
|
|
dataStore: st,
|
|
now: func() time.Time { return now },
|
|
ecosystem: stubEcosystem(nexus.URL, hexis.URL),
|
|
}, executed
|
|
}
|
|
|
|
func actDec(text string) router.Decision {
|
|
return router.Decision{Intent: router.IntentAct, Slots: router.Slots{Text: text, Fn: "restart", HasFn: true}}
|
|
}
|
|
|
|
func TestHexisMutatingRequiresConfirm(t *testing.T) {
|
|
ctx := context.Background()
|
|
resolved := `{"status":"resolved","entity":{"id":"ent_muzick","display_name":"Muzick indexer","type":"service"}}`
|
|
caps := `[{"id":"cap_restart","name":"restart","read_only":false,"risk":"high"}]`
|
|
h, executed := newHexisTestHandler(t, resolved, caps)
|
|
|
|
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
|
if !strings.Contains(reply, "да") {
|
|
t.Fatalf("mutating cap should ask to confirm, got %q", reply)
|
|
}
|
|
if *executed {
|
|
t.Fatal("mutating cap must NOT execute before confirmation")
|
|
}
|
|
if h.pendingHexis == nil || h.pendingHexis.capabilityID != "cap_restart" {
|
|
t.Fatalf("expected pending hexis bound to cap_restart, got %+v", h.pendingHexis)
|
|
}
|
|
|
|
// The follow-up "да" turn executes exactly the parked capability.
|
|
confirmReply, handled := h.resolveConfirm(ctx, "да")
|
|
if !handled || !strings.Contains(confirmReply, "выполнена") {
|
|
t.Fatalf("confirm should execute, got handled=%v reply=%q", handled, confirmReply)
|
|
}
|
|
if !*executed {
|
|
t.Fatal("confirmed mutating cap should have executed")
|
|
}
|
|
if h.pendingHexis != nil {
|
|
t.Fatal("pending should be cleared after confirm")
|
|
}
|
|
}
|
|
|
|
func TestHexisConfirmNoDoesNotExecute(t *testing.T) {
|
|
ctx := context.Background()
|
|
resolved := `{"status":"resolved","entity":{"id":"ent_muzick","display_name":"Muzick indexer","type":"service"}}`
|
|
caps := `[{"id":"cap_restart","name":"restart","read_only":false}]`
|
|
h, executed := newHexisTestHandler(t, resolved, caps)
|
|
|
|
_ = h.handleHexisAct(ctx, actDec("muzick indexer"))
|
|
reply, handled := h.resolveConfirm(ctx, "нет")
|
|
if !handled || !strings.Contains(reply, "отменила") {
|
|
t.Fatalf("no should cancel, got handled=%v reply=%q", handled, reply)
|
|
}
|
|
if *executed {
|
|
t.Fatal("declined cap must not execute")
|
|
}
|
|
}
|
|
|
|
func TestHexisReadOnlyExecutesImmediately(t *testing.T) {
|
|
ctx := context.Background()
|
|
resolved := `{"status":"resolved","entity":{"id":"ent_muzick","display_name":"Muzick indexer","type":"service"}}`
|
|
caps := `[{"id":"cap_status","name":"restart","read_only":true}]`
|
|
h, executed := newHexisTestHandler(t, resolved, caps)
|
|
|
|
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
|
if !*executed {
|
|
t.Fatal("read-only cap should execute without confirmation")
|
|
}
|
|
if h.pendingHexis != nil {
|
|
t.Fatal("read-only cap should not park a confirmation")
|
|
}
|
|
if !strings.Contains(reply, "выполнена") {
|
|
t.Fatalf("unexpected reply %q", reply)
|
|
}
|
|
}
|
|
|
|
func TestHexisAmbiguousAsksClarification(t *testing.T) {
|
|
ctx := context.Background()
|
|
ambiguous := `{"status":"ambiguous","candidates":[{"entity_id":"ent_muzick","display_name":"Muzick indexer"},{"entity_id":"ent_manga","display_name":"Manga indexer"}]}`
|
|
h, executed := newHexisTestHandler(t, ambiguous, `[]`)
|
|
|
|
reply := h.handleHexisAct(ctx, actDec("the indexer"))
|
|
if !strings.Contains(reply, "Muzick indexer") || !strings.Contains(reply, "Manga indexer") {
|
|
t.Fatalf("ambiguous should list candidates, got %q", reply)
|
|
}
|
|
if *executed {
|
|
t.Fatal("ambiguous target must never execute")
|
|
}
|
|
}
|