Take a module's condition on the operator channel's one subject (issue 1217)

This commit is contained in:
2026-10-19 11:58:10 +02:00
parent b4527d9091
commit de6741a335
7 changed files with 200 additions and 23 deletions
+9
View File
@@ -450,6 +450,15 @@ const ConditionOwner = "mesh-controller"
// grants that module alone. An error is returned only when the ask could not be recorded, so the queue
// offers it again; a refusal is said to the asker and the work is taken.
func (h *Holder) TakeAsk(verb, asker string, body json.RawMessage) error {
if verb == ConditionVerb {
// Before the work lock: Condition takes it, and decides as for the controller's conditions.
if !asks.UsableID(strings.ReplaceAll(asker, "-", "_")) {
h.Logf("[messenger] a condition from %q was taken and dropped: not a module's name", asker)
return nil
}
h.takeCondition(asker, body)
return nil
}
h.warmRootFree(true) // fresh, before the work: an ask refused for no channel stays refused with its asker
h.work.Lock()
defer h.work.Unlock()
@@ -38,6 +38,8 @@ import (
"fmt"
"strings"
"time"
"git.novox.be/novox/mesh-sdk/go/asks"
)
// The events, by their local names under the controller's seat.
@@ -341,3 +343,33 @@ func DecodeOpen(raw json.RawMessage) ([]Condition, error) {
}
return nil, fmt.Errorf("the conditions answer lists no conditions")
}
// ConditionVerb is the verb a module says a condition with: `mesh.seat.operator-channel.accept.condition.<publisher>`
// (novox/hq ADR 0437 §9).
const ConditionVerb = "condition"
// takeCondition takes one condition a module said on the seat's one subject. It is decided exactly as the
// controller's conditions are, by Condition: there is no second path. The publisher is the subject's last
// token, which the bus grants that module alone, so it is not compared with the condition's key. A body that is
// not a condition in the SDK's layout is logged by publisher, taken and dropped.
func (h *Holder) takeCondition(publisher string, body []byte) {
sc, err := asks.DecodeCondition(body)
if err != nil {
h.Logf("[messenger] a condition from %s was taken and dropped: %v", publisher, err)
return
}
c := Condition{Key: sc.Key, Scope: sc.Subject.Scope, ID: sc.Subject.ID, Machine: sc.Subject.Machine,
Severity: sc.Severity, Summary: sc.Summary, Headline: sc.Headline, Explanation: sc.Explanation,
Resolved: sc.Resolved, Needs: sc.Needs, Source: publisher}
if i := strings.LastIndex(sc.Key, "."); i >= 0 {
c.Kind = sc.Key[i+1:]
}
for _, a := range sc.Actions {
c.Actions = append(c.Actions, Action{Label: a.Label, Verb: a.Verb, Machine: a.Machine, Arguments: a.Arguments})
}
if sc.State == asks.StateCleared {
h.Condition(EventCleared, c)
return
}
h.Condition(EventRaised, c)
}
@@ -0,0 +1,134 @@
package main
import (
"encoding/json"
"errors"
"fmt"
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/asks"
)
// sdkCondition is a raised warning in mesh-sdk's layout, as a module says it on the operator channel's one subject.
func sdkCondition(needs string) asks.Condition {
verdict := asks.NothingToDo
if needs != "" {
verdict = asks.NeedsYou + needs
}
return asks.Condition{Key: "merge.d-7.stalled", Severity: asks.SeverityWarning,
Subject: asks.Scope{Scope: "merge", ID: "d-7"}, State: asks.StateRaised, Summary: "the merge waits on a check",
Headline: "A merge waits on a check", Needs: needs, Explanation: verdict + " It waits on a check."}
}
func conditionBody(t *testing.T, c asks.Condition) json.RawMessage {
t.Helper()
b, err := asks.EncodeCondition(c)
if err != nil {
t.Fatal(err)
}
return b
}
func TestARaisedConditionSaidOnTheOneSubjectIsToldAsTheControllersIs(t *testing.T) { // 1217/AC1
h, tg, dt, c, _ := newHolder(t)
if err := h.TakeAsk("condition", "mesh-delivery-teller", conditionBody(t, sdkCondition("look at it."))); err != nil {
t.Fatalf("not acknowledged: %v", err) // 1217/AC1: an error would offer the work again
}
open := h.Open()
if len(open) != 1 || open[0].Key != "merge.d-7.stalled" || open[0].Subject != "merge d-7" || open[0].Origin != "condition" {
t.Fatalf("open: %+v", open)
}
settle(h, c)
if len(dt.sends) != 1 || len(tg.sends) != 0 {
t.Fatalf("a warning goes to the desk when a session answers: desktop %d telegram %d", len(dt.sends), len(tg.sends))
}
dt.fail = errors.New("the account is not logged in")
c.pass(DesktopWarningEvery)
k := sdkCondition("look at it.")
k.Key, k.Subject.ID = "merge.d-8.stalled", "d-8"
if err := h.TakeAsk("condition", "mesh-delivery-teller", conditionBody(t, k)); err != nil {
t.Fatal(err)
}
settle(h, c) // the desktop fails: handed to Telegram
h.Flush()
if len(tg.sends) != 1 {
t.Fatalf("no session: telegram %d", len(tg.sends))
}
}
func TestTheSameConditionTakenTwiceIsSaidOnce(t *testing.T) { // 1217/AC2
h, tg, dt, c, _ := newHolder(t)
body := conditionBody(t, sdkCondition("look at it."))
for range 2 {
if err := h.TakeAsk("condition", "mesh-delivery-teller", body); err != nil {
t.Fatal(err)
}
}
settle(h, c)
if len(dt.sends)+len(tg.sends) != 1 {
t.Fatalf("said %d times, want once", len(dt.sends)+len(tg.sends))
}
if open := h.Open(); len(open) != 1 {
t.Fatalf("open: %+v", open)
}
}
func TestAClearedConditionIsClearedAsTheControllersIs(t *testing.T) { // 1217/AC3
h, _, _, c, _ := newHolder(t)
if err := h.TakeAsk("condition", "mesh-delivery-teller", conditionBody(t, sdkCondition("look at it."))); err != nil {
t.Fatal(err)
}
settle(h, c)
cleared := asks.Condition{Key: "merge.d-7.stalled", Subject: asks.Scope{Scope: "merge", ID: "d-7"}, State: asks.StateCleared}
if err := h.TakeAsk("condition", "mesh-delivery-teller", conditionBody(t, cleared)); err != nil {
t.Fatal(err)
}
if open := h.Open(); len(open) != 0 {
t.Fatalf("still open: %+v", open)
}
// The same as the controller's clearing of the same key.
h2, _, _, c2, _ := newHolder(t)
h2.Condition(EventRaised, cond("merge.d-7.stalled", Warning, "the merge waits on a check"))
settle(h2, c2)
h2.Condition(EventCleared, Condition{Key: "merge.d-7.stalled"})
if len(h2.Open()) != 0 {
t.Fatal("the controller's clearing leaves it open")
}
}
func TestAWarningThatNeedsNothingIsKeptQuiet(t *testing.T) { // 1217/AC4
h, tg, dt, c, _ := newHolder(t)
if err := h.TakeAsk("condition", "mesh-delivery-teller", conditionBody(t, sdkCondition(""))); err != nil {
t.Fatal(err)
}
settle(h, c)
if len(dt.sends)+len(tg.sends) != 0 {
t.Fatalf("a quiet warning was told: desktop %d telegram %d", len(dt.sends), len(tg.sends))
}
if len(h.Open()) != 1 {
t.Fatalf("it is still recorded: %+v", h.Open())
}
}
func TestABodyThatIsNotAConditionIsDroppedAndTheLogNamesThePublisher(t *testing.T) { // 1217/AC5
for name, body := range map[string]string{
"bad JSON": `{"v":1,`,
"unknown version": `{"v":9,"key":"merge.d-7.stalled","subject":{"scope":"merge","id":"d-7"},"state":"cleared"}`,
"no version": `{"key":"merge.d-7.stalled","subject":{"scope":"merge","id":"d-7"},"state":"cleared"}`,
} {
h, tg, dt, c, _ := newHolder(t)
var logged []string
h.Logf = func(f string, a ...any) { logged = append(logged, strings.TrimSpace(fmt.Sprintf(f, a...))) }
if err := h.TakeAsk("condition", "mesh-delivery-teller", json.RawMessage(body)); err != nil {
t.Fatalf("%s: the work is taken and dropped, got %v", name, err)
}
settle(h, c)
if len(h.Open()) != 0 || len(dt.sends)+len(tg.sends) != 0 {
t.Fatalf("%s: something was recorded or told", name)
}
if !strings.Contains(strings.Join(logged, "\n"), "mesh-delivery-teller") {
t.Fatalf("%s: the log does not name the publisher: %q", name, logged)
}
}
}
@@ -60,12 +60,12 @@ func readManifest(t *testing.T) (manifest, string) {
return m, string(raw)
}
func TestItDeclaresTheSeatsOfAnAskAndHoldsTheOperatorChannel(t *testing.T) {
func TestItDeclaresTheSeatsOfAnAskAndHoldsTheOperatorChannel(t *testing.T) { // 1217/AC6: condition beside ask and cancel
m, _ := readManifest(t)
verbs := []string{"open", "history", "notify", "tell", "asks"}
want := []seat{
{Name: asks.Seat, Scope: "mesh", Serves: verbs, Accepts: []string{"ask", "cancel"}, Emits: []string{"decided"},
ByCaller: []string{"ask", "cancel", "decided"}, Records: []string{"asks"}},
{Name: asks.Seat, Scope: "mesh", Serves: verbs, Accepts: []string{"ask", "cancel", "condition"}, Emits: []string{"decided"},
ByCaller: []string{"ask", "cancel", "condition", "decided"}, Records: []string{"asks"}},
{Name: asks.ChannelSeat, Scope: "mesh", Kinded: true, Accepts: []string{asks.Show, asks.Edit, asks.Send}},
{Name: asks.IntakeSeat, Scope: "mesh", Kinded: true,
Emits: []string{asks.Chosen, asks.Link, asks.FailedWhat, asks.StandingWhat}, Proofs: []string{asks.CodeProof}},
+7 -7
View File
@@ -1,22 +1,22 @@
module messenger
go 1.25.0
go 1.26.0
require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019091011-aa68b67389c9
github.com/nats-io/nats-server/v2 v2.11.17
github.com/nats-io/nats.go v1.51.0
github.com/nats-io/nats.go v1.54.0
)
require (
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op // indirect
github.com/google/go-tpm v0.9.8 // indirect
github.com/klauspost/compress v1.18.5 // indirect
github.com/klauspost/compress v1.20.0 // indirect
github.com/minio/highwayhash v1.0.4 // indirect
github.com/nats-io/jwt/v2 v2.8.1 // indirect
github.com/nats-io/nkeys v0.4.15 // indirect
github.com/nats-io/nkeys v0.4.16 // indirect
github.com/nats-io/nuid v1.0.1 // indirect
golang.org/x/crypto v0.50.0 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/crypto v0.57.0 // indirect
golang.org/x/sys v0.48.0 // indirect
golang.org/x/time v0.15.0 // indirect
)
+12 -12
View File
@@ -1,27 +1,27 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44 h1:r5xIo5qWOXQdCSMxvqJy8kV4U+P/QWXFK6971Ha6wU8=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019091011-aa68b67389c9 h1:qHpN/fpTDqijfMVoepxUT6mPkfcydb4LiPKsIokv6iM=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019091011-aa68b67389c9/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/minio/highwayhash v1.0.4 h1:asJizugGgchQod2ja9NJlGOWq4s7KsAWr5XUc9Clgl4=
github.com/minio/highwayhash v1.0.4/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ=
github.com/nats-io/jwt/v2 v2.8.1 h1:V0xpGuD/N8Mi+fQNDynXohVvp7ZztevW5io8CUWlPmU=
github.com/nats-io/jwt/v2 v2.8.1/go.mod h1:nWnOEEiVMiKHQpnAy4eXlizVEtSfzacZ1Q43LIRavZg=
github.com/nats-io/nats-server/v2 v2.11.17 h1:GKEghcFK6A+aFx11Yf1LjgLC3txAwvyhnYzhBIQZA8I=
github.com/nats-io/nats-server/v2 v2.11.17/go.mod h1:B1sFVz4StNosQ903ak4N1G01Fl/9f8e06mXpFIE2K24=
github.com/nats-io/nats.go v1.51.0 h1:ByW84XTz6W03GSSsygsZcA+xgKK8vPGaa/FCAAEHnAI=
github.com/nats-io/nats.go v1.51.0/go.mod h1:26HypzazeOkyO3/mqd1zZd53STJN0EjCYF9Uy2ZOBno=
github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4=
github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs=
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k=
github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg=
github.com/nats-io/nkeys v0.4.16/go.mod h1:llLgWoI0o4z/Q57q2R1kHfmocyhGV6VG/U18Glg1Afs=
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
+3 -1
View File
@@ -16,7 +16,8 @@
],
"accepts": [
"ask",
"cancel"
"cancel",
"condition"
],
"emits": [
"decided"
@@ -24,6 +25,7 @@
"by-caller": [
"ask",
"cancel",
"condition",
"decided"
],
"records": [