Every act owed to the forge is said as the seat's event owed under one id and stays on its delivery's or group's state until its owed.ended is recorded (milestone 47/G, W11.17a)
At the gate's start every unanswered act is said again under the same id; an outcome is written into the record that holds the pull request, then answered; an outcome other than performed is said; the checker's are not the gate's. Needs the forge bridge's forgemsg owed.ended (feat/47-G-forge). Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ (Joined from cf3715ea.) Builds: design 47 G §162, §163, §164, §165, §166 (GATE5) Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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, `<repository>.<number>.<head>.<act>` (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.<repository>.<number>`, 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=<context>` is a status, `comment.<seq>` a comment.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user