diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/bridge_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/bridge_test.go index 39eed29a2..b3e2c98b4 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/bridge_test.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/bridge_test.go @@ -2,12 +2,15 @@ package main import ( "errors" + "fmt" "sort" "strconv" "strings" "sync" + "time" mdc "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient" + seat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdelivery" "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg" ) @@ -137,57 +140,95 @@ func (s *fakeSource) Move(update func(prev *Source) (Source, bool)) error { func sortStrings(s []string) { sort.Strings(s) } func itoa(n int) string { return strconv.Itoa(n) } -// fakeOwed is the gate's bucket owed with the forge bridge behind it: every act written is performed on the fake forge, as the -// bridge performs it (mesh-delivery modules/mesh-delivery-forge-bridge internal/bridge acts.go OwedAct), once for each -// revision. With down set the bucket cannot be written (the bus is away); the forge's own refusals are the bridge's to say, -// not the gate's to retry. +// fakeOwed is the seat's event owed with the forge bridge behind it: every act said is performed on the fake forge, as the +// bridge performs it through the seat git (mesh-delivery modules/mesh-delivery-forge-bridge internal/bridge owed.go), once +// for each id, and answered as owed.ended performed unless silent is set. With down set nothing can be said (the bus is +// away); the forge's own refusals are the bridge's to say, not the gate's to retry. type fakeOwed struct { - mu sync.Mutex - forge *fakeForge - down bool - // keys are the latest value of each key written, and writes every revision in order. + mu sync.Mutex + forge *fakeForge + h *Holder + down bool + silent bool + // keys are the latest act said under each key, in the gate's working form, writes every key said in order, and said + // every event in order. keys map[string]Owed writes []string + said []seat.Owed + ids map[string]bool } -func newFakeOwed(f *fakeForge) *fakeOwed { return &fakeOwed{forge: f, keys: map[string]Owed{}} } +func newFakeOwed(f *fakeForge) *fakeOwed { + return &fakeOwed{forge: f, keys: map[string]Owed{}, ids: map[string]bool{}} +} -func (o *fakeOwed) Put(key string, v Owed) error { - if _, err := mdc.EncodeOwed(v); err != nil { - return err +func (o *fakeOwed) Say(v seat.Owed) error { + key, _, found := strings.Cut(v.ID, "@") + if !found { + return fmt.Errorf("the act's id %q holds no @", v.ID) } - repository, number, head, act, err := mdc.ParseOwedKey(key) + repository, number, head, _, err := mdc.ParseOwedKey(key) if err != nil { return err } + if repository != v.Repository || int64(number) != v.Number || head != v.Head && v.Commit != head { + return fmt.Errorf("the act %s names %s#%d at %s", v.ID, v.Repository, v.Number, v.Head) + } o.mu.Lock() if o.down { defer o.mu.Unlock() return errors.New("the bus is away") } - if kept, ok := o.keys[key]; ok && mdc.SameOwed(kept, v) { + o.said = append(o.said, v) + if o.ids[v.ID] { + // Said again: the bridge performs it once and answers it again from what it performed. + silent, h := o.silent, o.h o.mu.Unlock() + if !silent && h != nil { + return h.OwedEnded(owedEndedBody(v.ID, forgemsg.OwedPerformed, "")) + } return nil } - o.keys[key] = v + o.ids[v.ID] = true + working := Owed{Act: v.Act, Context: v.Context, State: v.State, Target: v.Target, Text: v.Text} + switch v.Act { + case seat.OwedActStatus: + working.Text = v.Description + case seat.OwedActNote: + working.Text = v.Line + } + o.keys[key] = working o.writes = append(o.writes, key) + silent, h := o.silent, o.h o.mu.Unlock() owner, repo, _ := strings.Cut(repository, "/") f := o.forge switch v.Act { - case mdc.OwedNote: - _ = f.Note(owner, repo, head, v.Text) - case mdc.OwedView: + case seat.OwedActNote: + _ = f.Note(owner, repo, head, v.Line) + case seat.OwedActView: _ = f.View(owner, repo, number, v.Text) - case mdc.OwedStatus: - _ = f.Status(owner, repo, head, v.Context, v.State, v.Text, v.Target) - case mdc.OwedComment: + case seat.OwedActStatus: + _ = f.Status(owner, repo, head, v.Context, v.State, v.Description, v.Target) + case seat.OwedActComment: _ = f.Comment(owner, repo, number, v.Text) } - _ = act + if !silent && h != nil { + return h.OwedEnded(owedEndedBody(v.ID, forgemsg.OwedPerformed, "")) + } return nil } +// owedEndedBody is the forge bridge's owed.ended for one of the gate's ids. +func owedEndedBody(id, outcome, words string) []byte { + raw, err := forgemsg.EncodeOwedEnded(forgemsg.OwedEnded{Of: seat.Seat, ID: id, Outcome: outcome, Words: words, + At: time.Date(2026, 10, 6, 12, 0, 0, 0, time.UTC)}) + if err != nil { + panic(err) + } + return raw +} + // acts are the keys written whose act is of a kind, in order. func (o *fakeOwed) acts(kind string) []string { o.mu.Lock() diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/bus_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/bus_test.go index 2bfb10adf..7eaeaa47d 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/bus_test.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/bus_test.go @@ -127,7 +127,9 @@ type runtime struct { operatorAsks []oc.Ask // operatorCancels are the ids of the asks taken back from the router, in order (issue 1079). operatorCancels []string - openPulls []OpenPull + // owedSaid are the subjects of the seat events owed the gate said, in order (W11.17). + owedSaid []string + openPulls []OpenPull } type askedOfMesh struct { @@ -311,6 +313,11 @@ func (r *runtime) answer(m rpcMessage) { r.operatorAsks = append(r.operatorAsks, a) r.mu.Unlock() } + if strings.HasPrefix(p.Subject, "mesh.seat.mesh-delivery.event.owed.") { + r.mu.Lock() + r.owedSaid = append(r.owedSaid, p.Subject) + r.mu.Unlock() + } if strings.Contains(p.Subject, ".accept.cancel.") { var c struct { ID string `json:"id"` @@ -593,9 +600,10 @@ func TestAPassingVerdictAsksTheApproveAndItsApproveMergesWithinSecondsOverARealB t.Fatalf("the gate asked %s %d times: the bridge writes to the forge", tool, n) } } - rt.until("the owed keys to be written", eventWithin, func() bool { - keys, err := b.kvs["owed"].Keys(ctx) - return err == nil && len(keys) > 0 + rt.until("the acts owed to be said as the seat's event owed", eventWithin, func() bool { + rt.mu.Lock() + defer rt.mu.Unlock() + return len(rt.owedSaid) > 0 && strings.HasSuffix(rt.owedSaid[0], ".7") }) // The operator approves; the teller holds the warrant and writes the answer: the merge is handed to the bridge within // seconds, with the Approve's heads, and the gate asks the forge nothing. diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/effects.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/effects.go index dcd1762fb..00bdb349d 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/effects.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/effects.go @@ -1,6 +1,8 @@ package main import ( + mdc "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient" + "encoding/json" "fmt" "sort" @@ -173,15 +175,29 @@ func (h *Holder) Flush() { ids = append(ids, id) } sort.Strings(ids) + // An act said and not yet answered by its owed.ended waits for that answer; at this owner's start it is said once more, + // under the same id (design 47: every unanswered act said again at its start). + again := !h.saidAtStart + h.saidAtStart = true + waiting := func(e Effect) bool { + return e.Next != nil && now.Before(*e.Next) || e.Said != nil && !again + } for _, id := range ids { d := h.deliveries[id] if h.dirty[id] { continue } - for _, e := range d.Owed { - if e.Next != nil && now.Before(*e.Next) { + for i, e := range d.Owed { + if waiting(e) { continue } + if e.ID == "" { + // The id is kept before the act is said, so its owed.ended finds it however soon it is heard. + if e.ID = actIDOf(e, d.Repository, d.Number, d.Commit, d.HTMLURL, false); e.ID != "" { + d.Owed[i].ID = e.ID + h.keep(d) + } + } j := job{id: id, e: e, repo: d.Repository, num: d.Number, head: d.Commit, url: d.HTMLURL} if e.Kind == EffectView { j.body = ViewBody(d, h.groups[d.Group]) @@ -193,17 +209,23 @@ func (h *Holder) Flush() { if h.dirtyGroup[id] { continue } - for _, e := range g.Owed { - if e.Next != nil && now.Before(*e.Next) { + for i, e := range g.Owed { + if waiting(e) { continue } + if e.ID == "" { + if e.ID = actIDOf(e, "", 0, "", "", true); e.ID != "" { + g.Owed[i].ID = e.ID + h.keepGroup(g) + } + } jobs = append(jobs, job{id: id, group: true, e: e}) } } h.mu.Unlock() for _, j := range jobs { - err := h.do(j.e, j.repo, j.num, j.head, j.body, j.url, j.group) + said, err := h.do(j.e, j.repo, j.num, j.head, j.body, j.url, j.group) h.mu.Lock() // Why it is given up, when it is: a failure of the bus tried retryLimit times, the last ones retryCap apart. dropped := "" @@ -214,6 +236,15 @@ func (h *Holder) Flush() { for i, o := range list { if o.Kind == j.e.Kind && o.Commit == j.e.Commit && o.Context == j.e.Context && o.Line == j.e.Line && o.Since.Equal(j.e.Since) { + if err == nil && said != "" { + // Said: it stays until its owed.ended is heard (OwedEnded). + at := h.Now() + if o.Said != nil { + at = *o.Said + } + list[i].ID, list[i].Said, list[i].Next, list[i].Tries = said, &at, nil, 0 + return list + } if err == nil || dropped != "" { return append(list[:i:i], list[i+1:]...) } @@ -307,13 +338,14 @@ func retryAfter(tries int) time.Duration { return min(wait, retryCap) } -// do is one owed effect: said on the bus, or written as the key the forge bridge performs. -func (h *Holder) do(e Effect, repository string, number int, head, body, url string, group bool) error { +// do is one owed effect: said on the bus, or said as the seat's event owed, which the forge bridge performs; said is the +// act's id when it was said as owed, and empty when nothing waits for an answer. +func (h *Holder) do(e Effect, repository string, number int, head, body, url string, group bool) (said string, err error) { switch e.Kind { case EffectEmit: var payload any if err := json.Unmarshal([]byte(e.Line), &payload); err != nil { - return nil // nothing sayable: dropped + return "", nil // nothing sayable: dropped } event := "transition" if group { @@ -322,10 +354,10 @@ func (h *Holder) do(e Effect, repository string, number int, head, body, url str if e.Event != "" { event = e.Event } - return h.Emit(event, payload) + return "", h.Emit(event, payload) case EffectView: if number == 0 { - return nil + return "", nil } } page := url @@ -336,7 +368,27 @@ func (h *Holder) do(e Effect, repository string, number int, head, body, url str } act, value, num, hd := owedOf(e, repository, number, head, body, page) if act == "" { - return nil + return "", nil } - return h.owe(repository, num, hd, act, value) + return h.owe(repository, num, hd, act, value, e.Since, actSays(e), e.ID) +} + +// actIDOf is the id an effect is said under as owed, as do says it; empty for an effect said otherwise or not at all. +func actIDOf(e Effect, repository string, number int, head, url string, group bool) string { + switch { + case e.Kind == EffectEmit, e.Kind == EffectView && number == 0: + return "" + case group && e.Kind == EffectStatus: + repository, number, url = memberOfLast(e.Last) + head = e.Commit + } + act, _, num, hd := owedOf(e, repository, number, head, "", url) + if act == "" { + return "" + } + key, err := mdc.OwedKey(repository, num, hd, act) + if err != nil { + return "" + } + return actID(key, e.Since, actSays(e)) } diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/fakes_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/fakes_test.go index 28842c05c..d966a7b8d 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/fakes_test.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/fakes_test.go @@ -323,6 +323,7 @@ func newWorldOver(t *testing.T, store *memStore, forge *fakeForge, now time.Time w.h.Merges, w.h.Source = w.merges, w.source w.merges.h = w.h w.owed = newFakeOwed(forge) + w.owed.h = w.h w.h.Owed = w.owed w.h.init() if err := w.h.Load(); err != nil { diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/holder.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/holder.go index b22b758d5..14a0cf246 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/holder.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/holder.go @@ -36,8 +36,8 @@ type Holder struct { Operator Operator Merges MergeStore Source SourceStore - // Owed is the gate's bucket owed: what a transition owes the forge, written as keys (owed.go). - Owed OwedStore + // Owed says what a transition owes the forge as the seat's event owed (owed.go). + Owed OwedSayer // Records keeps the delivery records on the gate's ledger (record.go, ADR 0656 §7); nil keeps none. Records *Records // Consumer applies the step events to the records (consumer.go); nil applies none. @@ -102,6 +102,8 @@ type Holder struct { reach map[string]viewstate.Reach // tried counts the owed effects Flush tried since this owner started, by how each ended (novox/hq issue 407). tried owedTries + // saidAtStart is whether this owner has said again, once, every act still unanswered (Flush). + saidAtStart bool // forgeWanted is whether the forge's open pull requests are to be read at the next React: a check may have // answered (events.go). reactMu keeps one React at a time. forgeWanted bool diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue1349_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue1349_test.go index 4ea57265d..bc3160bfa 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue1349_test.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue1349_test.go @@ -38,7 +38,7 @@ func TestATransitionWritesItsStatusViewAndNoteAsOwedKeys(t *testing.T) { if v := w.owed.keys[viewKey]; v.Act != mdc.OwedView || !strings.Contains(v.Text, id) { t.Fatalf("the owed view is %+v", v) } - if n := w.owed.keys[noteKey]; n.Act != mdc.OwedNote || n.Ref != "mesh-plan" || !strings.Contains(n.Text, "proposed") { + if n := w.owed.keys[noteKey]; n.Act != mdc.OwedNote || !strings.Contains(n.Text, "proposed") { t.Fatalf("the owed note is %+v", n) } // What the bridge would perform is what the forge holds. diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/main.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/main.go index 1fd9bcaa4..50a28b098 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/main.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/main.go @@ -35,7 +35,7 @@ func (l *listening) get() string { l.mu.Lock(); defer l.mu.Unlock(); return l.n // The events this owner follows: the forge's pull requests, as the seat git says them (gitseat.go). The forge's status events // say that a check answered (events.go). The step events the delivery record is built from follow (consumer.go). -var followed = append(append(append([]string{}, gitEvents...), forgePattern, ConfigChangedEvent), stepPatterns()...) +var followed = append(append(append([]string{}, gitEvents...), forgePattern, owedEndedEvent, ConfigChangedEvent), stepPatterns()...) // forgePattern is the forge bridge's messages on FORGE (novox/hq ADR 0437 §8): the gate follows a merge and the refusal of one. const forgePattern = "mesh-delivery-forge-bridge.forge.**" @@ -67,7 +67,7 @@ func main() { Merges: kvMerges{}, Source: kvSource{}, Turns: kvTurns{}, - Owed: kvOwed{}, + Owed: seatOwed{say: stdio.SeatPublish}, Pulls: kvPulls{}, // The gate says what stalled to the operator itself (issue 1275, ADR 0437 §9). Say: sayOnSeat, @@ -209,6 +209,9 @@ func take(h *Holder, e stdio.Envelope) error { if h.Consumer != nil && h.Consumer.Takes(e.Key) { return h.Consumer.Hear(e) } + if e.Key == owedEndedEvent { + return h.OwedEnded(e.Body) + } if strings.HasPrefix(e.Key, "mesh-delivery-forge-bridge.forge.") { h.Forged(e.Key, e.Body) return nil diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/model.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/model.go index 3b5b444ff..37e27c0bd 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/model.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/model.go @@ -217,6 +217,9 @@ type Effect struct { Since time.Time `json:"since"` // Next is when a failed effect is tried again: later after every failure, up to retryCap. Next *time.Time `json:"next,omitempty"` + // ID and Said are the act's id and when it was first said as the event owed: it stays until its owed.ended is heard. + ID string `json:"id,omitempty"` + Said *time.Time `json:"said,omitempty"` } // The kinds of effect. diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/owed.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/owed.go index 2d5763d8d..9ee4587e9 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/owed.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/owed.go @@ -1,22 +1,25 @@ package main import ( - "encoding/json" "errors" "fmt" + "hash/fnv" "strconv" "strings" + "time" mdc "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient" - stdio "git.novox.be/novox/mesh-sdk/go" + seat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdelivery" + "git.novox.be/novox/mesh-sdk/go/keys" ) -// The gate writes nothing to the forge: what a transition owes it — a status, the view on the pull request, a line of the -// commit's note, a comment — is one key of the gate's bucket owed, `...` (meshdeliveryclient -// OwedKey), which the forge bridge performs once for each revision (novox/hq ADR 0437 §8, ADR 0450 §4). The number is 0 for -// a commit no pull request names; a comment's act carries a sequence number, so two comments on one head are two acts. -// What the gate keeps is the effect owed until its key is written (effects.go); the forge's own refusal is the bridge's -// to say on FORGE, and the gate has no retry for it. +// The gate writes nothing to the forge: each act a transition owes it — a status, the view on the pull request, a line of +// the commit's note, a comment — is said as the event owed of the seat mesh-delivery, on +// `mesh.seat.mesh-delivery.event.owed..`, under one id, which the forge bridge performs through the +// seat git and answers as owed.ended (design 47 *What the gate owes the forge*). The act stays on its delivery's or group's +// state, already on the bus, until its owed.ended is heard; at the gate's start every act still unanswered is said again +// under the same id, and the forge holds it once. The number is 0 for a commit no pull request names; a comment's act +// carries a sequence number, so two comments on one head are two acts. // noPage is the target of a status whose delivery has no page to point at. const noPage = "none" @@ -24,34 +27,35 @@ const noPage = "none" // notesRef is the notes ref the commit's note is kept under. const notesRef = "mesh-plan" -// OwedStore is the gate's bucket owed. -type OwedStore interface { - // Put writes the act under its key, unless that key holds the same act already (a revision is an act to perform). - Put(key string, o Owed) error +// OwedSayer says one act owed to the forge as the seat mesh-delivery's event owed. +type OwedSayer interface { + Say(o seat.Owed) error } -// kvOwed is owed over the runtime. -type kvOwed struct{} +// seatOwed says owed on the seat's subject, under the act's id, which the bus keeps once. +type seatOwed struct{ say SeatSay } -func (kvOwed) Put(key string, o Owed) error { - raw, err := mdc.EncodeOwed(o) - if err != nil { - return err - } - if e, err := stdio.State("owed").Get(key); err == nil && e != nil { - if kept, err := mdc.DecodeOwed(e.Value); err == nil && mdc.SameOwed(kept, o) { - return nil - } - } - _, err = stdio.State("owed").Put(key, json.RawMessage(raw)) - return err +func (s seatOwed) Say(o seat.Owed) error { + return s.say(seat.SubjectOwed(keys.EscapeName(o.Repository), strconv.FormatInt(o.Number, 10)), o, "owed."+o.ID) } -// owe writes one owed act as its key. An act that has no key (a name the key refuses, a status with no page to point at) is -// said and dropped, since writing it again would fail the same. -func (h *Holder) owe(repository string, number int, head, act string, o Owed) error { +// actID is the id of an act owed: its key, when it was first owed and a digest of what it says, so it is the same each time +// it is said again and two acts of one key (a status set twice, two lines of the note in one moment) are two ids. It holds +// an @, which a switched act's id never holds. +func actID(key string, since time.Time, says string) string { + d := fnv.New32a() + _, _ = d.Write([]byte(says)) + return key + "@" + strconv.FormatInt(since.UnixNano(), 10) + "." + strconv.FormatUint(uint64(d.Sum32()), 16) +} + +// actSays is what an effect says, as its id's digest reads it. +func actSays(e Effect) string { return e.Kind + "\n" + e.Context + "\n" + e.State + "\n" + e.Line } + +// owe says one owed act under its id. An act that has no key (a name the key refuses, a status with no page to point at) +// is said as refused and dropped (an empty id), since saying it again would fail the same. +func (h *Holder) owe(repository string, number int, head, act string, o Owed, since time.Time, says, want string) (id string, err error) { if h.Owed == nil { - return errors.New("no owed bucket") + return "", errors.New("no seat to say owed on") } key, err := mdc.OwedKey(repository, number, head, act) if err == nil { @@ -60,9 +64,30 @@ func (h *Holder) owe(repository string, number int, head, act string, o Owed) er } if err != nil { h.Refuse(fmt.Errorf("the act %q on %s#%d at %s cannot be owed and is dropped: %v", act, repository, number, shortOf(head, 12), err)) - return nil + return "", nil } - return h.Owed.Put(key, o) + if id = actID(key, since, says); want != "" { + id = want + } + said := since.UTC() + return id, h.Owed.Say(seatOwedOf(o, id, repository, number, head, &said)) +} + +// seatOwedOf is an act in the gate's working form as the seat's event owed says it. +func seatOwedOf(o Owed, id, repository string, number int, head string, said *time.Time) seat.Owed { + out := seat.Owed{ID: id, Repository: repository, Number: int64(number), Head: head, Said: said, Text: o.Text} + switch o.Act { + case mdc.OwedStatus: + out.Act, out.Context, out.State, out.Description, out.Target, out.Commit = seat.OwedActStatus, o.Context, o.State, o.Text, o.Target, head + out.Text = "" + case mdc.OwedNote: + out.Act, out.Line, out.Commit, out.Text = seat.OwedActNote, o.Text, head, "" + case mdc.OwedView: + out.Act = seat.OwedActView + case mdc.OwedComment: + out.Act = seat.OwedActComment + } + return out } // actKind is the kind of an owed act: `status=` is a status, `comment.` a comment. diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/owed_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/owed_test.go new file mode 100644 index 000000000..6c1546f18 --- /dev/null +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/owed_test.go @@ -0,0 +1,107 @@ +package main + +import ( + "strings" + "testing" + + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg" +) + +// W11.17 (design 47 *What the gate owes the forge*): every act owed is said as the seat's event owed under one id and stays +// on its delivery's state until its owed.ended is heard; at the owner's start every unanswered act is said again under the +// same id; an outcome is written into the record holding the pull request before the act is answered, once; an outcome +// other than performed is said; the checker's outcomes are not the gate's. +func TestAnActOwedStaysUntilItsOutcomeIsRecorded(t *testing.T) { + w := newWorld(t) + w.owed.silent = true + l := newLedgerFake(t) + id := IDOf("novox/app", head) + w.h.Records = &Records{Ledger: l, Now: w.h.Now} + if _, _, err := w.h.Records.Open("feat/x", map[string]string{id: head}, false); err != nil { + t.Fatal(err) + } + w.h.PullUpdated(pr("novox/app", 7, head, "feat/x", "src/a.go")) + w.settleAll() + w.settleAll() + held := map[string]bool{} + for _, e := range w.delivery(id).Owed { + if e.Said != nil && e.ID != "" { + held[e.ID] = true + } + } + if len(held) < 3 { + t.Fatalf("the acts said and held on the delivery: %v; its owed %+v", held, w.delivery(id).Owed) + } + saidOnce := map[string]int{} + for _, o := range w.owed.said { + saidOnce[o.ID]++ + if !held[o.ID] && strings.Contains(o.ID, ".view@") { + t.Errorf("the view %s was said but is not held", o.ID) + } + } + for heldID := range held { + if saidOnce[heldID] != 1 { + t.Errorf("%s was said %d times before any answer, want once", heldID, saidOnce[heldID]) + } + } + + // The owner starts again over the same state: every unanswered act is said again, under the same id. + w2 := newWorldOver(t, w.store, w.forge, w.now) + w2.owed.silent = true + w2.h.Records = &Records{Ledger: l, Now: w2.h.Now} + w2.h.Flush() + again := map[string]bool{} + for _, o := range w2.owed.said { + again[o.ID] = true + } + for heldID := range held { + if !again[heldID] { + t.Errorf("%s was not said again at the start; said %v", heldID, again) + } + } + w2.h.Flush() + if len(w2.owed.said) != len(again) { + t.Errorf("a second flush said %d acts again, want none", len(w2.owed.said)-len(again)) + } + + var one string + for heldID := range held { + one = heldID + break + } + // The checker's outcome of an id is not the gate's: nothing is answered. + checkers, _ := forgemsg.EncodeOwedEnded(forgemsg.OwedEnded{Of: "mesh-delivery-checker", ID: one, Outcome: forgemsg.OwedPerformed, At: w.now}) + if err := w2.h.OwedEnded(checkers); err != nil || !owedHolds(w2, id, one) { + t.Fatalf("the checker's outcome answered the gate's act: %v", err) + } + // The ledger away: the act is not answered, and the event is heard again. + l.failOn = OpAct + if err := w2.h.OwedEnded(owedEndedBody(one, forgemsg.OwedRefused, "the branch is protected")); err == nil || !owedHolds(w2, id, one) { + t.Fatalf("an outcome the record could not take answered the act: %v", err) + } + l.failOn = "" + if err := w2.h.OwedEnded(owedEndedBody(one, forgemsg.OwedRefused, "the branch is protected")); err != nil || owedHolds(w2, id, one) { + t.Fatalf("the refused outcome did not answer the act: %v", err) + } + records, _ := w2.h.Records.Current() + if a := records[0].Acts[one]; a.Outcome != forgemsg.OwedRefused || a.Words != "the branch is protected" || a.Head != head || a.Number != 7 { + t.Fatalf("the record holds the act as %+v", a) + } + if !strings.Contains(w2.logs(), "was refused: the branch is protected") { + t.Errorf("a refused act was not said: %s", w2.logs()) + } + seq := records[0].Sequence + if err := w2.h.OwedEnded(owedEndedBody(one, forgemsg.OwedRefused, "the branch is protected")); err != nil || records[0].Sequence != seq { + t.Errorf("an outcome heard twice changed the record (%d, was %d): %v", records[0].Sequence, seq, err) + } +} + +// owedHolds is whether the delivery still holds the act unanswered. +func owedHolds(w *world, id, act string) bool { + for _, e := range w.delivery(id).Owed { + if e.ID == act { + return true + } + } + return false +} diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/owedended.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/owedended.go new file mode 100644 index 000000000..2f76733ad --- /dev/null +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/owedended.go @@ -0,0 +1,156 @@ +package main + +import ( + "fmt" + "strings" + "time" + + seat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdelivery" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg" +) + +// owedEndedEvent is the forge bridge's word on an act owed, owed.ended on its stream forge (design 47 *What the gate owes +// the forge*): the outcome of one id, the gate's own or the checker's. +const owedEndedEvent = forgeBridgeModule + "." + forgemsg.OwedEndedEvent + +// OwedEnded takes the forge bridge's outcome of one act this owner said as owed. The outcome is written into the delivery +// record that holds the pull request at that head, and only then is the act answered: taken off its delivery's or group's +// state. An outcome heard twice, or of an id this owner holds no longer, changes nothing. An outcome other than performed +// is said. An error leaves the event to be heard again: the record could not take it. +func (h *Holder) OwedEnded(body []byte) error { + e, err := forgemsg.DecodeOwedEnded(body) + if err != nil { + h.Refuse(fmt.Errorf("an owed act's outcome the forge bridge said is not readable: %v", err)) + return nil + } + if e.Of != seat.Seat { + return nil // the checker's act + } + if e.ID == "" { + h.Refuse(fmt.Errorf("the forge bridge could not read an act this owner said: %s", e.Words)) + return nil + } + h.mu.Lock() + held, ok := h.heldAct(e.ID) + h.mu.Unlock() + if !ok { + return nil + } + if e.Outcome != forgemsg.OwedPerformed { + h.Refuse(fmt.Errorf("%s: the %s owed on %s#%d at %s was %s: %s", held.owner, held.effect.Kind, held.repository, + held.number, shortOf(held.head, 12), e.Outcome, e.Words)) + } + if err := h.recordAct(held, e); err != nil { + return err + } + h.mu.Lock() + defer h.mu.Unlock() + h.answer(e.ID) + return nil +} + +// heldAct is an act said and not yet answered, with where it is held. Under the lock. +type heldAct struct { + owner string // the delivery's or the group's id + effect Effect + repository, head string + number int +} + +func (h *Holder) heldAct(id string) (heldAct, bool) { + for did, d := range h.deliveries { + for _, o := range d.Owed { + if o.ID == id { + head := d.Commit + if o.Commit != "" { + head = o.Commit + } + return heldAct{owner: did, effect: o, repository: d.Repository, head: head, number: d.Number}, true + } + } + } + for gid, g := range h.groups { + for _, o := range g.Owed { + if o.ID == id { + repository, number, _ := memberOfLast(o.Last) + return heldAct{owner: gid, effect: o, repository: repository, head: o.Commit, number: number}, true + } + } + } + return heldAct{}, false +} + +// answer takes an answered act off its delivery's or group's state. Under the lock. +func (h *Holder) answer(id string) { + drop := func(list []Effect) ([]Effect, bool) { + for i, o := range list { + if o.ID == id { + return append(list[:i:i], list[i+1:]...), true + } + } + return list, false + } + for _, d := range h.deliveries { + var dropped bool + if d.Owed, dropped = drop(d.Owed); dropped { + h.keep(d) + } + } + for _, g := range h.groups { + var dropped bool + if g.Owed, dropped = drop(g.Owed); dropped { + h.keepGroup(g) + } + } +} + +// recordAct writes an act's outcome into the delivery record that holds its pull request at its head; none holds it +// before the Approve's merge is handed (switch.go), and then the outcome is on its delivery's transitions alone. +func (h *Holder) recordAct(a heldAct, e forgemsg.OwedEnded) error { + if h.Records == nil { + return nil + } + current, err := h.Records.Current() + if err != nil { + return err + } + prefix := strings.ToLower(a.repository) + "@" + for _, r := range current { + for member, head := range r.Heads { + if head == a.head && strings.HasPrefix(strings.ToLower(member), prefix) { + at := e.At.UTC() + return h.Records.Act(r, RecordAct{ID: e.ID, Act: a.effect.Kind, Repository: a.repository, Number: a.number, + Head: a.head, Outcome: e.Outcome, Words: e.Words, At: at}) + } + } + } + return nil +} + +// RecordAct is one act the gate owed the forge for a pull request the record holds, with the forge bridge's outcome. +type RecordAct struct { + ID string `json:"id"` + Act string `json:"act"` + Repository string `json:"repository"` + Number int `json:"number"` + Head string `json:"head"` + Outcome string `json:"outcome"` + Words string `json:"words,omitempty"` + At time.Time `json:"at"` +} + +// Act writes one act's outcome into the record, unless it holds that outcome already. +func (rs *Records) Act(r *Record, a RecordAct) error { + if had, ok := r.Acts[a.ID]; ok && had.Outcome == a.Outcome { + return nil + } + seq, err := rs.Ledger.Apply(r.Instance, OpAct, a) + if err != nil { + return err + } + if r.Acts == nil { + r.Acts = map[string]RecordAct{} + } + r.Acts[a.ID], r.Sequence = a, seq + return nil +} diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/record.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/record.go index 4bf027ce0..9cd40c3ce 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/record.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/record.go @@ -53,13 +53,14 @@ const ( OpPin = "pin" // set-entry into pinned, by revision OpFree = "free" // remove from pinned, by revision OpEnd = "end" // set-fields: ended + OpAct = "act" // set-entry into acts, by id: an act owed the forge with its outcome OpRemove = "remove" // remove the instance, at its retention OpHold = "hold" // a month's list: set-entry into records, by instance OpDrop = "drop" // a month's list: remove from records, by instance ) // RecordOperations are a record's operations, in the order the manifest's type allows them. -var RecordOperations = []string{OpOpen, OpGroup, OpTrace, OpEntry, OpOutcome, OpAway, OpBack, OpPin, OpFree, OpEnd, OpRemove} +var RecordOperations = []string{OpOpen, OpGroup, OpTrace, OpEntry, OpOutcome, OpAway, OpBack, OpPin, OpFree, OpAct, OpEnd, OpRemove} // HeldOperations are the month's list's operations. var HeldOperations = []string{OpHold, OpDrop} @@ -94,7 +95,9 @@ type Record struct { Away map[string]NodeAway `json:"away,omitempty"` // Pinned are the graph revisions the record points at, by revision. Pinned map[string]PinnedRevision `json:"pinned,omitempty"` - Ended *RecordEnded `json:"ended,omitempty"` + // Acts are the acts owed the forge for the record's pull requests, by id, with their outcomes (owedended.go). + Acts map[string]RecordAct `json:"acts,omitempty"` + Ended *RecordEnded `json:"ended,omitempty"` } // RecordGroup is the group's state as the record keeps it: what `merges` held for the group, and its members. diff --git a/modules/mesh-delivery-gate/module.json b/modules/mesh-delivery-gate/module.json index d766a5f54..190578972 100644 --- a/modules/mesh-delivery-gate/module.json +++ b/modules/mesh-delivery-gate/module.json @@ -27,6 +27,7 @@ "git.forge.status", "git.repo.writable", "mesh-delivery-forge-bridge.forge.**", + "mesh-delivery-forge-bridge.owed.ended", "mesh-config.config-changed", "mesh-delivery-indexer.take-in.taken" ], @@ -109,6 +110,24 @@ }, "ended": { "type": "object" + }, + "id": { + "type": "string" + }, + "act": { + "type": "string" + }, + "repository": { + "type": "string" + }, + "number": { + "type": "integer" + }, + "head": { + "type": "string" + }, + "words": { + "type": "string" } }, "operations": [ @@ -203,6 +222,23 @@ "into": "pinned", "key": "revision" }, + { + "name": "act", + "effect": "set-entry", + "fields": [ + "id", + "act", + "repository", + "number", + "head", + "outcome", + "words", + "at" + ], + "into": "acts", + "key": "id", + "sequence": true + }, { "name": "end", "effect": "set-fields",