diff --git a/modules/mesh-delivery-forge-bridge/go.mod b/modules/mesh-delivery-forge-bridge/go.mod index 691a687..0e8c1ba 100644 --- a/modules/mesh-delivery-forge-bridge/go.mod +++ b/modules/mesh-delivery-forge-bridge/go.mod @@ -3,7 +3,7 @@ module mesh-delivery-forge-bridge go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019230201-5786ba190c69 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261020011849-ba684e54976b github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 ) diff --git a/modules/mesh-delivery-forge-bridge/go.sum b/modules/mesh-delivery-forge-bridge/go.sum index d96e678..489afbc 100644 --- a/modules/mesh-delivery-forge-bridge/go.sum +++ b/modules/mesh-delivery-forge-bridge/go.sum @@ -2,6 +2,8 @@ git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019195003-4580d80a32d9 h1:XL1dbtSl git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019195003-4580d80a32d9/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019230201-5786ba190c69 h1:kvtV/SPsdRVUL/BZ3xxGS6U8YrXy/t7TRBjVrD6VLw8= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019230201-5786ba190c69/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261020011849-ba684e54976b h1:U7++vBnHNWRYpwdbO+Tx03HUcEzDFLBciNDe0fNiXYc= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261020011849-ba684e54976b/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= diff --git a/modules/mesh-delivery-indexer/go.mod b/modules/mesh-delivery-indexer/go.mod index 02011be..c329852 100644 --- a/modules/mesh-delivery-indexer/go.mod +++ b/modules/mesh-delivery-indexer/go.mod @@ -3,7 +3,7 @@ module mesh-delivery-indexer go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019230201-5786ba190c69 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261020011849-ba684e54976b github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 ) diff --git a/modules/mesh-delivery-indexer/go.sum b/modules/mesh-delivery-indexer/go.sum index e7d24d6..04a9085 100644 --- a/modules/mesh-delivery-indexer/go.sum +++ b/modules/mesh-delivery-indexer/go.sum @@ -4,6 +4,8 @@ git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019223929-58373d65b47a h1:Vjk2Kq+g git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019223929-58373d65b47a/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019230201-5786ba190c69 h1:kvtV/SPsdRVUL/BZ3xxGS6U8YrXy/t7TRBjVrD6VLw8= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019230201-5786ba190c69/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261020011849-ba684e54976b h1:U7++vBnHNWRYpwdbO+Tx03HUcEzDFLBciNDe0fNiXYc= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261020011849-ba684e54976b/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= diff --git a/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/teller.go b/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/teller.go index 921961f..a4ed730 100644 --- a/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/teller.go +++ b/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/teller.go @@ -69,29 +69,44 @@ func (t *Teller) logf(format string, args ...any) { } } -// seen is what the teller keeps beside an ask it made: the gate key it was made for and the revision of that key it was -// made at, so a replay of the revision asks nothing again. +// seen is what the teller keeps beside an ask it made. Under `key.` it names the gate key the asking was made for; +// under `gate.` it names the router id of the latest asking of that key and the revision of the key it was made +// at, so a replay of the revision asks nothing again and an overwrite with a new id is known for a new asking. type seen struct { - Key string `json:"key"` + Key string `json:"key,omitempty"` + ID string `json:"id,omitempty"` Rev uint64 `json:"rev"` } -func recordKey(id string) string { return "key." + id } -func keptKey(id string) string { return "ask." + id } -func condKey(id string) string { return "cond." + id } +func gateRecordKey(key string) string { return "gate." + key } +func recordKey(id string) string { return "key." + id } +func keptKey(id string) string { return "ask." + id } +func condKey(id string) string { return "cond." + id } -func (t *Teller) seenOf(id string) (seen, *stdio.StateEntry, error) { - e, err := t.Store.Get(recordKey(id)) +// condID is what a condition of an ask is kept and named by: the gate key it was asked for, so the asking that follows a bad +// answer clears the condition of the one before it. An id the teller holds no key for is its own. +func (t *Teller) condID(id string) string { + if s, e, _ := t.seenOf(id); e != nil && s.Key != "" { + return s.Key + } + return id +} + +func (t *Teller) seenAt(rk string) (seen, *stdio.StateEntry, error) { + e, err := t.Store.Get(rk) if err != nil || e == nil { return seen{}, nil, err } var s seen if err := json.Unmarshal(e.Value, &s); err != nil { - return seen{}, nil, fmt.Errorf("the record %s cannot be read: %w", recordKey(id), err) + return seen{}, nil, fmt.Errorf("the record %s cannot be read: %w", rk, err) } return s, e, nil } +// seenOf is the record of a router id: the gate key it was asked for. +func (t *Teller) seenOf(id string) (seen, *stdio.StateEntry, error) { return t.seenAt(recordKey(id)) } + func (t *Teller) keptOf(id string) (*asker.Kept, uint64, error) { e, err := t.Store.Get(keptKey(id)) if err != nil || e == nil { @@ -107,21 +122,27 @@ func (t *Teller) keptOf(id string) (*asker.Kept, uint64, error) { // open says whether an ask is made and neither answered nor ended. func open(k *asker.Kept) bool { return k != nil && !k.Acted && k.Ended == "" } +// note keeps that the asking with this router id is the latest of the gate key, made at revision rev of it. func (t *Teller) note(id, key string, rev uint64, prior *stdio.StateEntry) error { - s := seen{Key: key, Rev: rev} - if prior == nil { - _, err := t.Store.Create(recordKey(id), s) + if _, err := t.Store.Create(recordKey(id), seen{Key: key, Rev: rev}); err != nil && !errors.Is(err, stdio.ErrChanged) { return err } - _, err := t.Store.Update(recordKey(id), s, prior.Revision) + g := seen{ID: id, Rev: rev} + if prior == nil { + _, err := t.Store.Create(gateRecordKey(key), g) + return err + } + _, err := t.Store.Update(gateRecordKey(key), g, prior.Revision) return err } -// OnAsk handles the gate's asks key put at revision rev: the ask it holds is published to the router once per -// revision of its key. A key whose value is not the ask its name says, one already answered, and a revision the teller -// has handled are left. An ask still open and unchanged is only noted (and its warrant read from the router, in case it -// came while the teller was away); one whose words changed is cancelled and asked afresh; one that ended, or that the -// teller cancelled, is asked again under the same id when the gate writes its key again (ADR 0441 ยง1). +// OnAsk handles the gate's asks key put at revision rev. Each asking has its own router id, which is the value's ask.id and +// never one recomputed from the key (mesh-sdk go/delivery Ask.Asking, AskingID): the router keeps an ended ask and drops a +// second ask under its id. So the ask is published once per id; a key overwritten at a higher revision under a new id is a +// new asking, and the earlier asking's router ask is cancelled first. A key whose value is not the ask its name says, one +// already answered, and a revision the teller has handled are left. An ask still open and unchanged is only noted (and its +// warrant read from the router, in case it came while the teller was away). A value that changed its words under the id +// it was asked under is cancelled and not asked again: the router would drop it, so the gate must give it a new asking. func (t *Teller) OnAsk(key string, rev uint64, value []byte) { t.mu.Lock() defer t.mu.Unlock() @@ -141,41 +162,42 @@ func (t *Teller) OnAsk(key string, rev uint64, value []byte) { } else if has { return } - prior, srec, err := t.seenOf(id) + prior, grec, err := t.seenAt(gateRecordKey(key)) if err != nil { t.logf("%s: %v", key, err) return } - k, krev, err := t.keptOf(id) - if err != nil { - t.logf("%s: %v", key, err) - return - } - if srec != nil && rev <= prior.Rev { - if open(k) { - t.catchUp(id) + if grec != nil && rev <= prior.Rev { + if k, _, err := t.keptOf(prior.ID); err == nil && open(k) { + t.catchUp(prior.ID) } return } + if grec != nil && prior.ID != id { + // A new asking of the group at these heads: the earlier one is taken back. + t.cancelOpen(key, prior.ID) + } + k, _, err := t.keptOf(id) + if err != nil { + t.logf("%s: %v", key, err) + return + } if k != nil { - if open(k) && k.Ask.Digest() == ga.Ask.Digest() { - _ = t.note(id, key, rev, srec) + switch { + case open(k) && k.Ask.Digest() == ga.Ask.Digest(): + _ = t.note(id, key, rev, grec) t.catchUp(id) - return - } - if open(k) { - if err := t.Asker.Cancel(id); err != nil { - t.logf("%s: the ask %s changed and could not be cancelled, so it is left: %v", key, id, err) - return - } - t.logf("%s: the ask %s changed at revision %d: the old one is cancelled and the new one asked", key, id, rev) - } - if err := t.Store.Delete(keptKey(id)); err != nil { - t.logf("%s: the earlier ask %s (revision %d of its record) could not be forgotten: %v", key, id, krev, err) - return + case open(k): + t.cancelOpen(key, id) + _ = t.note(id, key, rev, grec) + t.logf("%s: the ask %s changed its words at revision %d under the same id: cancelled, and not asked again, for the router drops a second ask under an id (the gate gives a new asking a new id)", key, id, rev) + default: + _ = t.note(id, key, rev, grec) + t.logf("%s: the ask %s ended, and the gate wrote it again under the same id at revision %d: not asked again, the router would drop it", key, id, rev) } + return } - if err := t.note(id, key, rev, srec); err != nil { + if err := t.note(id, key, rev, grec); err != nil { t.logf("%s: the record of the ask %s could not be kept, so it is left: %v", key, id, err) return } @@ -187,19 +209,12 @@ func (t *Teller) OnAsk(key string, rev uint64, value []byte) { } return } - t.logf("%s at revision %d: the ask %s is asked of the operator", key, rev, id) + t.logf("%s at revision %d: the ask %s is asked of the operator (asking %d)", key, rev, id, ga.Asking) } -// OnAskGone handles the gate's asks key taken away (a withdrawn ask): the ask is cancelled at the router, and an answer -// to it merges nothing. -func (t *Teller) OnAskGone(key string) { - t.mu.Lock() - defer t.mu.Unlock() - if _, _, _, err := delivery.ParseAskKey(key); err != nil { - t.logf("the gate's key %s was taken away and is no ask key, and is left: %v", key, err) - return - } - id := delivery.AskID(key) +// cancelOpen takes an open ask back at the router and marks it ended here, since the router's word that it ended may come +// later. An ask that is not open is left. +func (t *Teller) cancelOpen(key, id string) { k, krev, err := t.keptOf(id) if err != nil { t.logf("%s: %v", key, err) @@ -209,15 +224,33 @@ func (t *Teller) OnAskGone(key string) { return } if err := t.Asker.Cancel(id); err != nil { - t.logf("%s: the withdrawn ask %s could not be cancelled: %v", key, id, err) + t.logf("%s: the ask %s could not be cancelled: %v", key, id, err) return } - // The router's word that it ended may come later: until it does, the ask is not open here. k.Ended = asks.OutcomeCancelled if _, err := t.Store.Update(keptKey(id), k, krev); err != nil && !errors.Is(err, stdio.ErrChanged) { t.logf("%s: the cancelled ask %s could not be marked: %v", key, id, err) } - t.logf("%s: the gate withdrew the ask %s: cancelled", key, id) + t.logf("%s: the ask %s is cancelled", key, id) +} + +// OnAskGone handles the gate's asks key taken away (a withdrawn ask): the latest asking's router ask is cancelled, and an +// answer to it merges nothing. +func (t *Teller) OnAskGone(key string) { + t.mu.Lock() + defer t.mu.Unlock() + if _, _, _, err := delivery.ParseAskKey(key); err != nil { + t.logf("the gate's key %s was taken away and is no ask key, and is left: %v", key, err) + return + } + prior, grec, err := t.seenAt(gateRecordKey(key)) + if err != nil || grec == nil { + if err != nil { + t.logf("%s: %v", key, err) + } + return + } + t.cancelOpen(key, prior.ID) } // catchUp reads the router's record of an ask the teller made and hands its warrant on, as the router's event would: @@ -249,7 +282,8 @@ func mergeAct(ask, option string, heads map[string]string) asks.Act { } // headsOf is the heads an ask covers as an act names them: a delivery's id, `owner/repository@` (mesh-catalog modules/mesh-delivery model.go Delivery.ID), and its whole commit. +// commit>` with the repository in lower case, as the gate's IDOf spells it (mesh-catalog modules/mesh-delivery-gate model.go), +// and its whole commit. func headsOf(heads []delivery.Head) map[string]string { out := map[string]string{} for _, h := range heads { @@ -257,7 +291,7 @@ func headsOf(heads []delivery.Head) map[string]string { if len(short) > 12 { short = short[:12] } - out[h.Repository+"@"+short] = h.Head + out[strings.ToLower(h.Repository)+"@"+short] = h.Head } return out } @@ -290,7 +324,11 @@ func (t *Teller) Answer(o asks.Option, w asks.Warrant) error { if err != nil { return fmt.Errorf("the gate's ask %s is not an ask: %w", s.Key, err) } - if ga.Ask.ID != w.Ask || ga.Ask.Digest() != w.AskDigest { + if ga.Ask.ID != w.Ask { + t.logf("%s: the ask %s was answered, and the gate has asked again under %s: an answer to an earlier asking merges nothing", s.Key, w.Ask, ga.Ask.ID) + return nil + } + if ga.Ask.Digest() != w.AskDigest { return fmt.Errorf("the gate's ask %s is no longer the ask the operator answered (its digest is %s, the warrant's %s)", s.Key, ga.Ask.Digest(), w.AskDigest) } @@ -345,10 +383,11 @@ func oneLine(err error) string { // conditionKey is the key of the condition about an ask. func conditionKey(id string) string { return delivery.TellerModule + ".merge-ask." + id } -func (t *Teller) raise(id, headline, why string) { +func (t *Teller) raise(askID, headline, why string) { if t.Say == nil { return } + id := t.condID(askID) if _, err := t.Store.Create(condKey(id), seen{}); err != nil { if errors.Is(err, stdio.ErrChanged) { return // raised already @@ -366,10 +405,11 @@ func (t *Teller) raise(id, headline, why string) { } } -func (t *Teller) clear(id string) { +func (t *Teller) clear(askID string) { if t.Say == nil { return } + id := t.condID(askID) e, err := t.Store.Get(condKey(id)) if err != nil || e == nil { return diff --git a/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/teller_test.go b/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/teller_test.go index 4c5f18e..20cdcf5 100644 --- a/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/teller_test.go +++ b/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/teller_test.go @@ -97,6 +97,11 @@ type bed struct { pubs []published conds []asks.Condition records map[string]string + // seenIDs are the ids the fake router has taken an ask under, and dropped the asks it took a second time under: the + // messenger keeps an ask after it ends and returns for an ask whose id it holds (mesh-catalog modules/messenger + // cmd/messenger/asks.go:487-489, keepAsk), so a reused id reaches no phone. + seenIDs map[string]bool + dropped []string hear func(stdio.Envelope) error logs strings.Builder } @@ -104,10 +109,16 @@ type bed struct { func newBed(t *testing.T) *bed { t.Helper() b := &bed{store: &memStore{entries: map[string]*stdio.StateEntry{}}, answers: &memAnswers{written: map[string][]byte{}}, - gate: memGate{}, records: map[string]string{}} + gate: memGate{}, records: map[string]string{}, seenIDs: map[string]bool{}} b.client = &asker.Client{Module: delivery.TellerModule, State: b.store, Now: func() time.Time { return now }, Publish: func(subject string, body any, id string) error { raw, err := json.Marshal(body) + if subject == asks.AskSubject(delivery.TellerModule) { + if b.seenIDs[id] { + b.dropped = append(b.dropped, id) + } + b.seenIDs[id] = true + } b.pubs = append(b.pubs, published{subject, raw, id}) return err }, @@ -139,12 +150,19 @@ func newBed(t *testing.T) *bed { // and :63-65 mergeAct), under the id its key gives (mesh-sdk go/delivery AskID). boundTo are the heads the options bind, // which are the ask's own unless a test says another. func gateAsk(t *testing.T, heads, boundTo []delivery.Head) (key string, value []byte, ask delivery.Ask) { + t.Helper() + return gateAskN(t, heads, boundTo, 1) +} + +// gateAskN is the asking-th writing of the group's ask at these heads: its router id is AskingID(key, asking) (mesh-sdk +// go/delivery Ask.Asking), the first asking's the key's own. +func gateAskN(t *testing.T, heads, boundTo []delivery.Head, asking int) (key string, value []byte, ask delivery.Ask) { t.Helper() key, err := delivery.MergeAskKey("g", heads) if err != nil { t.Fatal(err) } - id := delivery.AskID(key) + id := delivery.AskingID(key, asking) a := asks.Ask{ID: id, Headline: "Merge into main: novox/mesh-delivery#12", Explanation: "Merges two pull requests.\nReview: approved.", Who: asks.Operator, Expires: now.Add(asks.ApproveLasts - 10*time.Minute), OnExpiry: "the merge stays held", About: "delivery.mesh-delivery.12"} for _, o := range []struct{ id, label, does string }{{"approve", "Approve", "merges"}, {"decline", "Decline", "merges nothing"}} { @@ -154,7 +172,7 @@ func gateAsk(t *testing.T, heads, boundTo []delivery.Head) (key string, value [] } a.Options = append(a.Options, asks.Option{ID: o.id, Label: o.label, Does: o.does, Level: asks.Approve, Binds: binds}) } - ask = delivery.Ask{Group: "g", Heads: heads, Ask: a} + ask = delivery.Ask{Group: "g", Heads: heads, Asking: asking, Ask: a} value, err = delivery.EncodeAsk(ask) if err != nil { t.Fatal(err) @@ -287,7 +305,7 @@ func TestAWarrantThatDoesNotHoldWritesNoAnswerAndRaisesTheCondition(t *testing.T if len(b.answers.written) != 0 { t.Errorf("an answer was written: %v", b.answers.written) } - if len(b.conds) != 1 || b.conds[0].State != asks.StateRaised || b.conds[0].Subject.ID != delivery.AskID(key) || + if len(b.conds) != 1 || b.conds[0].State != asks.StateRaised || b.conds[0].Subject.ID != key || !strings.HasPrefix(b.conds[0].Explanation, asks.NeedsYou) { t.Fatalf("conditions = %+v", b.conds) } @@ -305,8 +323,8 @@ func TestAGoodAnswerClearsTheConditionOfAnAskAskedAgain(t *testing.T) { // 1343/ if len(b.conds) != 1 || b.conds[0].State != asks.StateRaised { t.Fatalf("conditions = %+v", b.conds) } - // The gate writes the ask again, right this time, at a new revision of its key: asked again under the same id. - _, good, gaGood := gateAsk(t, twoHeads(), twoHeads()) + // The gate writes the ask again, right this time, at a new revision of its key: the second asking, under a new id. + _, good, gaGood := gateAskN(t, twoHeads(), twoHeads(), 2) b.gate[key] = good b.t.OnAsk(key, 2, good) if n := len(b.askedOf(asks.AskSubject(delivery.TellerModule))); n != 2 { @@ -334,7 +352,45 @@ func TestAWarrantComingWhileTheTellerWasAwayIsReadFromTheRouter(t *testing.T) { } } -func TestAnAskWhoseWordsChangedIsCancelledAndAskedAgain(t *testing.T) { +func TestARewriteWithAskingTwoCancelsAskingOneAndAsksUnderTheNewID(t *testing.T) { // G2 of review round 1 + b := newBed(t) + key, one, g1 := gateAskN(t, twoHeads(), twoHeads(), 1) + b.gate[key] = one + b.t.OnAsk(key, 1, one) + _, two, g2 := gateAskN(t, twoHeads(), twoHeads(), 2) + if g1.Ask.ID == g2.Ask.ID { + t.Fatal("the two askings share an id") + } + b.gate[key] = two + b.t.OnAsk(key, 2, two) + cancels := b.askedOf(asks.CancelSubject(delivery.TellerModule)) + if len(cancels) != 1 || string(cancels[0].body) != `{"id":"`+g1.Ask.ID+`"}` { + t.Fatalf("cancels = %+v, want asking 1's router ask", cancels) + } + asked := b.askedOf(asks.AskSubject(delivery.TellerModule)) + if len(asked) != 2 || asked[1].id != g2.Ask.ID { + t.Fatalf("asks = %+v, want the second under %s", asked, g2.Ask.ID) + } + if len(b.dropped) != 0 { + t.Errorf("the router dropped asks under a reused id: %v", b.dropped) + } + // The same revision again, and a warrant for the first asking (the gate has moved on), change nothing. + b.t.OnAsk(key, 2, two) + b.deliver(t, warrantFor(g1.Ask, "approve")) + if len(b.answers.written) != 0 || len(b.askedOf(asks.AskSubject(delivery.TellerModule))) != 2 { + t.Errorf("answers %v asks %d", b.answers.written, len(b.askedOf(asks.AskSubject(delivery.TellerModule)))) + } + // The warrant of the second names it: its answer's warrant is the router id and the time. + w := warrantFor(g2.Ask, "approve") + b.deliver(t, w) + raw, ok := b.answers.written[delivery.AnswerKey(key)] + a, err := delivery.DecodeAnswer(raw) + if !ok || err != nil || a.Warrant != g2.Ask.ID+"@"+w.At.UTC().Format(time.RFC3339Nano) { + t.Errorf("answer %+v (%v) err %v", a, ok, err) + } +} + +func TestAnAskWhoseWordsChangedUnderTheSameIDIsCancelledAndNotAskedAgain(t *testing.T) { b := newBed(t) key, value, ga := gateAsk(t, twoHeads(), twoHeads()) b.t.OnAsk(key, 1, value) @@ -348,8 +404,35 @@ func TestAnAskWhoseWordsChangedIsCancelledAndAskedAgain(t *testing.T) { if n := len(b.askedOf(asks.CancelSubject(delivery.TellerModule))); n != 1 { t.Errorf("cancels = %d", n) } - if n := len(b.askedOf(asks.AskSubject(delivery.TellerModule))); n != 2 { - t.Errorf("asks = %d", n) + if n := len(b.askedOf(asks.AskSubject(delivery.TellerModule))); n != 1 || len(b.dropped) != 0 { + t.Errorf("asks = %d dropped %v: the router would drop a second ask under the id", n, b.dropped) + } +} + +func TestACapitalisedRepositoryIsSpelledInLowerCaseInTheAct(t *testing.T) { // T1 of review round 1 + b := newBed(t) + heads := []delivery.Head{{Repository: "novox/Mesh-Delivery", Number: 12, Head: headA}} + key, err := delivery.MergeAskKey("g", heads) + if err != nil { + t.Fatal(err) + } + id := delivery.AskID(key) + // The act as the gate binds it: its IDOf lowercases the repository (mesh-catalog modules/mesh-delivery-gate model.go). + binds, err := asks.ActDigest(asks.Act{"verb": "mesh-delivery.merge", "ask": id, "option": "approve", "heads": "novox/mesh-delivery@" + headA[:12] + "=" + headA}) + if err != nil { + t.Fatal(err) + } + a := asks.Ask{ID: id, Headline: "Merge into main: Mesh-Delivery#12", Explanation: "Merges.", Who: asks.Operator, Expires: now.Add(asks.ApproveLasts - 10*time.Minute), + OnExpiry: "the merge stays held", Options: []asks.Option{{ID: "approve", Label: "Approve", Does: "merges", Level: asks.Approve, Binds: binds}}} + value, err := delivery.EncodeAsk(delivery.Ask{Group: "g", Heads: heads, Ask: a}) + if err != nil { + t.Fatal(err) + } + b.gate[key] = value + b.t.OnAsk(key, 1, value) + b.deliver(t, warrantFor(a, "approve")) + if _, ok := b.answers.written[delivery.AnswerKey(key)]; !ok { + t.Errorf("no answer for the gate's spelling: %s", b.logs.String()) } } diff --git a/modules/mesh-delivery-teller/go.mod b/modules/mesh-delivery-teller/go.mod index c977aa1..e2ff8ac 100644 --- a/modules/mesh-delivery-teller/go.mod +++ b/modules/mesh-delivery-teller/go.mod @@ -3,7 +3,7 @@ module mesh-delivery-teller go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019230201-5786ba190c69 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261020011849-ba684e54976b github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 ) diff --git a/modules/mesh-delivery-teller/go.sum b/modules/mesh-delivery-teller/go.sum index fcb33b1..ad79b8e 100644 --- a/modules/mesh-delivery-teller/go.sum +++ b/modules/mesh-delivery-teller/go.sum @@ -1,5 +1,7 @@ git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019230201-5786ba190c69 h1:kvtV/SPsdRVUL/BZ3xxGS6U8YrXy/t7TRBjVrD6VLw8= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019230201-5786ba190c69/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261020011849-ba684e54976b h1:U7++vBnHNWRYpwdbO+Tx03HUcEzDFLBciNDe0fNiXYc= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261020011849-ba684e54976b/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= diff --git a/modules/mesh-delivery-view/go.mod b/modules/mesh-delivery-view/go.mod index 37a8ead..33ebd9a 100644 --- a/modules/mesh-delivery-view/go.mod +++ b/modules/mesh-delivery-view/go.mod @@ -3,7 +3,7 @@ module mesh-delivery-view go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019230201-5786ba190c69 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261020011849-ba684e54976b github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 ) diff --git a/modules/mesh-delivery-view/go.sum b/modules/mesh-delivery-view/go.sum index e7d24d6..04a9085 100644 --- a/modules/mesh-delivery-view/go.sum +++ b/modules/mesh-delivery-view/go.sum @@ -4,6 +4,8 @@ git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019223929-58373d65b47a h1:Vjk2Kq+g git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019223929-58373d65b47a/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019230201-5786ba190c69 h1:kvtV/SPsdRVUL/BZ3xxGS6U8YrXy/t7TRBjVrD6VLw8= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019230201-5786ba190c69/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261020011849-ba684e54976b h1:U7++vBnHNWRYpwdbO+Tx03HUcEzDFLBciNDe0fNiXYc= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261020011849-ba684e54976b/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= diff --git a/modules/mesh-delivery-view/internal/view/asks_test.go b/modules/mesh-delivery-view/internal/view/asks_test.go index b155085..1d32b1b 100644 --- a/modules/mesh-delivery-view/internal/view/asks_test.go +++ b/modules/mesh-delivery-view/internal/view/asks_test.go @@ -130,3 +130,27 @@ func TestTheSnapshotsJSONCarriesTheAsks(t *testing.T) { // 1345/AC2 t.Errorf("json %s: %+v %v", raw, back, err) } } + +func TestAHaveOfARemovedModuleIsShownRemovedNotLeftBehind(t *testing.T) { // 1370: the node-engine writes phase removed + m := New() + fill(m, haveE(t, "novox", "mesh-delivery", have(t, "b1", delivery.PhaseRemoved, delivery.HealthOK, ""), t0), + haveE(t, "novox", "stray", have(t, "b2", delivery.PhaseRunning, delivery.HealthOK, ""), t0)) + s := m.Snapshot() + if r := find(t, s, "novox", "mesh-delivery"); r.Status != StatusRemoved { + t.Errorf("row = %+v, want status %q", r, StatusRemoved) + } + if r := find(t, s, "novox", "stray"); r.Status != StatusLeftBehind { + t.Errorf("row = %+v, want left behind", r) + } + var out bytes.Buffer + Table(&out, s) + line := "" + for _, l := range strings.Split(out.String(), "\n") { + if strings.Contains(l, "mesh-delivery") { + line = l + } + } + if !strings.Contains(line, "removed") || strings.Contains(line, "left behind") { + t.Errorf("table line %q", line) + } +} diff --git a/modules/mesh-delivery-view/internal/view/serve.go b/modules/mesh-delivery-view/internal/view/serve.go index 73fa01e..3678c6c 100644 --- a/modules/mesh-delivery-view/internal/view/serve.go +++ b/modules/mesh-delivery-view/internal/view/serve.go @@ -111,9 +111,9 @@ func rowText(r Row) []string { if r.Error != "" { what = "unreadable: " + r.Error } - if r.Status == StatusLeftBehind && what == "" { + if (r.Status == StatusLeftBehind || r.Status == StatusRemoved) && what == "" { what = r.Status - } else if r.Status == StatusLeftBehind { + } else if r.Status == StatusLeftBehind || r.Status == StatusRemoved { what = r.Status + "; " + what } return []string{r.Node, r.Module, r.Assigned, r.Have, r.Phase, r.Health, since(r.Since), r.Detail, what} diff --git a/modules/mesh-delivery-view/internal/view/view.go b/modules/mesh-delivery-view/internal/view/view.go index 6caaf3c..0406e39 100644 --- a/modules/mesh-delivery-view/internal/view/view.go +++ b/modules/mesh-delivery-view/internal/view/view.go @@ -396,6 +396,10 @@ func (m *Model) Snapshot() Snapshot { } node, module, _, _ := delivery.ParseHaveKey(key) r := Row{Node: node, Module: module, Status: StatusLeftBehind, Since: h.at, Error: h.err} + if h.err == "" && h.have.Phase == delivery.PhaseRemoved { + // The node-engine says it took the module away: plainly removed, not left behind. + r.Status = StatusRemoved + } if h.err == "" { r.Have, r.Phase, r.Health, r.Detail = h.have.Build, string(h.have.Phase), string(h.have.Health), h.have.Detail } @@ -567,6 +571,7 @@ const ( const ( StatusAssigned = "assigned" StatusLeftBehind = "left behind" + StatusRemoved = "removed" ) // askRows are the readable asks with no answer under their key, ordered by key: each with its headline, its words, the