557 lines
18 KiB
Go
557 lines
18 KiB
Go
package main
|
|
|
|
// The overview page (novox/hq to-be 55 §11, ADR 0616): one read-only page for the operator with three blocks: every design in
|
|
// progress with its milestones and their issues; the decisions and research efforts accepted in the last seven days and those
|
|
// waiting; and what waits on the operator (records waiting for an Approve, the operator channel's open asks, the delivery
|
|
// groups waiting). Built from mesh paths alone: the module's own ledger, and three other modules' verbs for the first view and
|
|
// their events after it. It never polls: a source is asked again only when it is marked unanswered, on a bounded backoff.
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
stdio "git.novox.be/novox/mesh-sdk/go"
|
|
)
|
|
|
|
// The sources of the page, besides the module's own ledger.
|
|
const (
|
|
SourceTracker = "issue-tracker"
|
|
SourceDelivery = "mesh-delivery"
|
|
SourceAsks = "operator-channel"
|
|
)
|
|
|
|
// The verbs of the first view, and the events that keep each source current.
|
|
const (
|
|
TrackerMilestonesVerb = "seat:issue-tracker.milestones"
|
|
TrackerListVerb = "seat:issue-tracker.list"
|
|
DeliveryGroupsVerb = "seat:mesh-delivery.groups"
|
|
AsksVerb = "seat:operator-channel.asks"
|
|
)
|
|
|
|
// OverviewEvents are the events the page consumes, by source, as the manifest declares them.
|
|
var OverviewEvents = map[string][]string{
|
|
SourceTracker: {"mesh-issues.opened", "mesh-issues.moved", "mesh-issues.milestone-set", "mesh-issues.milestone-moved"},
|
|
SourceDelivery: {"git.pull-merged", "mesh-delivery-gate.transition", "mesh-pipeline-runner.group-ended"},
|
|
SourceAsks: {"messenger.offered", "messenger.closed"},
|
|
}
|
|
|
|
// The unanswered marks: a verb that did not answer, or a subscription that missed its heartbeat.
|
|
const (
|
|
MarkVerb = "verb"
|
|
MarkSubscription = "subscription"
|
|
)
|
|
|
|
// Backoff for a source marked from its verb: from 10 seconds, doubling, at most every 5 minutes (ADR 0616 §6).
|
|
const (
|
|
BackoffFrom = 10 * time.Second
|
|
BackoffMost = 5 * time.Minute
|
|
// HeartbeatWithin is how long a subscription may be silent before its source is marked.
|
|
HeartbeatWithin = 90 * time.Second
|
|
// RecentFor is the window of the accepted records block, counted from the ledger's dates when the page is answered.
|
|
RecentFor = 7 * 24 * time.Hour
|
|
)
|
|
|
|
// askRow is an open ask of the operator channel.
|
|
type askRow struct {
|
|
Key string `json:"key"`
|
|
Asker string `json:"asker"`
|
|
Headline string `json:"headline"`
|
|
State string `json:"state,omitempty"`
|
|
Opened string `json:"opened,omitempty"`
|
|
Offered []string `json:"offered_on,omitempty"`
|
|
}
|
|
|
|
// sourceMark says a source did not answer, and why.
|
|
type sourceMark struct {
|
|
Cause string `json:"cause"`
|
|
Why string `json:"why"`
|
|
Since time.Time `json:"since"`
|
|
// Next is when the verb is asked again (a mark from the verb only).
|
|
Next time.Time `json:"next,omitempty"`
|
|
Backoff time.Duration `json:"-"`
|
|
}
|
|
|
|
// SourceState is one source on the page: answered, or marked, with the time of its last answer.
|
|
type SourceState struct {
|
|
Name string `json:"name"`
|
|
Answered bool `json:"answered"`
|
|
Mark *sourceMark `json:"mark,omitempty"`
|
|
Last time.Time `json:"last-answer,omitempty"`
|
|
}
|
|
|
|
// RecordRow is a record on the page.
|
|
type RecordRow struct {
|
|
Record string `json:"record"`
|
|
Title string `json:"title"`
|
|
Status string `json:"status"`
|
|
Date string `json:"date,omitempty"`
|
|
Waits string `json:"waits,omitempty"`
|
|
}
|
|
|
|
// MilestoneRow is a milestone with its issues on the page.
|
|
type MilestoneRow struct {
|
|
Design int `json:"design"`
|
|
ID string `json:"id"`
|
|
Outcome string `json:"outcome"`
|
|
Status string `json:"status"`
|
|
Issues []IssueRow `json:"issues"`
|
|
}
|
|
|
|
// IssueRow is an issue of a milestone.
|
|
type IssueRow struct {
|
|
Number int `json:"number"`
|
|
Title string `json:"title,omitempty"`
|
|
Status string `json:"status,omitempty"`
|
|
}
|
|
|
|
// OverviewModel is the page's model, as the page renders it and the tool answers it: the same data.
|
|
type OverviewModel struct {
|
|
At time.Time `json:"at"`
|
|
Designs []MilestoneRow `json:"milestones"`
|
|
Accepted []RecordRow `json:"accepted-in-seven-days"`
|
|
Waiting []RecordRow `json:"records-waiting"`
|
|
Operator OperatorBlock `json:"waiting-on-the-operator"`
|
|
Sources []SourceState `json:"sources"`
|
|
LastRebuilt time.Time `json:"last-rebuilt"`
|
|
}
|
|
|
|
// OperatorBlock is what waits on the operator.
|
|
type OperatorBlock struct {
|
|
Records []RecordRow `json:"records"`
|
|
Asks []askRow `json:"asks"`
|
|
Groups []groupRow `json:"delivery-groups"`
|
|
}
|
|
|
|
// Overview is the page's model in memory, kept by events.
|
|
type Overview struct {
|
|
// Ask reaches a verb on a seat; Records reads the module's own ledger; Now is the clock.
|
|
Ask SeatAsk
|
|
Records func() ([]*Entry, error)
|
|
Now func() time.Time
|
|
|
|
mu sync.Mutex
|
|
milestones map[string]trackerMilestone
|
|
issues map[int]trackerRow
|
|
groups map[string]groupRow
|
|
asks map[string]askRow
|
|
state map[string]*SourceState
|
|
rebuilt time.Time
|
|
// verbCalls counts the verbs asked, so a test sees that an event asked none.
|
|
verbCalls map[string]int
|
|
}
|
|
|
|
// NewOverview is a model with no source answered: Start asks each source's verb once.
|
|
func NewOverview(ask SeatAsk, records func() ([]*Entry, error), now func() time.Time) *Overview {
|
|
o := &Overview{Ask: ask, Records: records, Now: now, milestones: map[string]trackerMilestone{}, issues: map[int]trackerRow{},
|
|
groups: map[string]groupRow{}, asks: map[string]askRow{}, state: map[string]*SourceState{}, verbCalls: map[string]int{}}
|
|
for _, s := range []string{SourceTracker, SourceDelivery, SourceAsks} {
|
|
o.state[s] = &SourceState{Name: s}
|
|
}
|
|
return o
|
|
}
|
|
|
|
func (o *Overview) now() time.Time {
|
|
if o.Now != nil {
|
|
return o.Now().UTC()
|
|
}
|
|
return time.Now().UTC()
|
|
}
|
|
|
|
// rowsOf reads the rows a verb answers: a bare list, or an object holding one list.
|
|
func rowsOf(raw json.RawMessage, into any, field string) error {
|
|
if err := json.Unmarshal(raw, into); err == nil {
|
|
return nil
|
|
}
|
|
var wrapped map[string]json.RawMessage
|
|
if err := json.Unmarshal(raw, &wrapped); err != nil {
|
|
return err
|
|
}
|
|
if v, ok := wrapped[field]; ok {
|
|
return json.Unmarshal(v, into)
|
|
}
|
|
return fmt.Errorf("no %s in the answer", field)
|
|
}
|
|
|
|
func milestoneKey(design int, id string) string { return fmt.Sprintf("%d/%s", design, id) }
|
|
|
|
// Rebuild asks a source's verb and rebuilds its block from the answer, clearing its mark; a verb that does not answer marks
|
|
// the source from the verb, with the next try on the backoff. It is the first view at start, the answer to a mark, and the
|
|
// hand act of the re-read tool.
|
|
func (o *Overview) Rebuild(source string) error {
|
|
o.mu.Lock()
|
|
defer o.mu.Unlock()
|
|
return o.rebuildLocked(source)
|
|
}
|
|
|
|
func (o *Overview) rebuildLocked(source string) error {
|
|
st := o.state[source]
|
|
if st == nil {
|
|
return fmt.Errorf("%q is no source of the page (%s)", source, strings.Join(o.sourceNames(), ", "))
|
|
}
|
|
call := func(verb string, body any) (json.RawMessage, error) {
|
|
o.verbCalls[verb]++
|
|
return o.Ask(verb, body)
|
|
}
|
|
fail := func(why string) error {
|
|
prev := st.Mark
|
|
back := BackoffFrom
|
|
if prev != nil && prev.Cause == MarkVerb {
|
|
back = min(prev.Backoff*2, BackoffMost)
|
|
}
|
|
since := o.now()
|
|
if prev != nil {
|
|
since = prev.Since
|
|
}
|
|
st.Answered, st.Mark = false, &sourceMark{Cause: MarkVerb, Why: why, Since: since, Next: o.now().Add(back), Backoff: back}
|
|
return fmt.Errorf("%s does not answer: %s", source, why)
|
|
}
|
|
switch source {
|
|
case SourceTracker:
|
|
raw, err := call(TrackerMilestonesVerb, map[string]any{})
|
|
if err != nil {
|
|
return fail(err.Error())
|
|
}
|
|
var ms []trackerMilestone
|
|
if err := rowsOf(raw, &ms, "milestones"); err != nil {
|
|
return fail("milestones answered something else: " + err.Error())
|
|
}
|
|
raw, err = call(TrackerListVerb, map[string]any{})
|
|
if err != nil {
|
|
return fail(err.Error())
|
|
}
|
|
var rows []trackerRow
|
|
if err := rowsOf(raw, &rows, "issues"); err != nil {
|
|
return fail("list answered something else: " + err.Error())
|
|
}
|
|
o.milestones, o.issues = map[string]trackerMilestone{}, map[int]trackerRow{}
|
|
for _, m := range ms {
|
|
o.milestones[milestoneKey(m.Design, m.ID)] = m
|
|
}
|
|
for _, r := range rows {
|
|
o.issues[r.Number] = r
|
|
}
|
|
case SourceDelivery:
|
|
raw, err := call(DeliveryGroupsVerb, map[string]any{})
|
|
if err != nil {
|
|
return fail(err.Error())
|
|
}
|
|
var gs []groupRow
|
|
if err := rowsOf(raw, &gs, "groups"); err != nil {
|
|
return fail("groups answered something else: " + err.Error())
|
|
}
|
|
o.groups = map[string]groupRow{}
|
|
for _, g := range gs {
|
|
o.groups[g.ID] = g
|
|
}
|
|
case SourceAsks:
|
|
raw, err := call(AsksVerb, map[string]any{})
|
|
if err != nil {
|
|
return fail(err.Error())
|
|
}
|
|
var as []askRow
|
|
if err := rowsOf(raw, &as, "asks"); err != nil {
|
|
return fail("asks answered something else: " + err.Error())
|
|
}
|
|
o.asks = map[string]askRow{}
|
|
for _, a := range as {
|
|
if a.State == "" || a.State == "open" || a.State == "held" {
|
|
o.asks[a.Key] = a
|
|
}
|
|
}
|
|
}
|
|
st.Answered, st.Mark, st.Last = true, nil, o.now()
|
|
o.rebuilt = o.now()
|
|
return nil
|
|
}
|
|
|
|
func (o *Overview) sourceNames() []string {
|
|
out := make([]string, 0, len(o.state))
|
|
for n := range o.state {
|
|
out = append(out, n)
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
|
|
// Start is the first view: every source's verb asked once. A verb that does not answer marks its source.
|
|
func (o *Overview) Start() {
|
|
for _, s := range []string{SourceTracker, SourceDelivery, SourceAsks} {
|
|
_ = o.Rebuild(s)
|
|
}
|
|
}
|
|
|
|
// Due asks again, once, every source marked from its verb whose backoff has run out: the only asks the page makes after start
|
|
// besides the one a subscription's return makes. It answers the sources asked.
|
|
func (o *Overview) Due() []string {
|
|
o.mu.Lock()
|
|
defer o.mu.Unlock()
|
|
var asked []string
|
|
for _, s := range o.sourceNames() {
|
|
if m := o.state[s].Mark; m != nil && m.Cause == MarkVerb && !o.now().Before(m.Next) {
|
|
_ = o.rebuildLocked(s)
|
|
asked = append(asked, s)
|
|
}
|
|
}
|
|
return asked
|
|
}
|
|
|
|
// Silent marks the sources whose subscription has been silent past its bound: nothing is asked while it is silent.
|
|
func (o *Overview) Silent() []string {
|
|
o.mu.Lock()
|
|
defer o.mu.Unlock()
|
|
var marked []string
|
|
for _, s := range o.sourceNames() {
|
|
st := o.state[s]
|
|
if st.Answered && st.Mark == nil && !st.Last.IsZero() && o.now().Sub(st.Last) > HeartbeatWithin {
|
|
st.Answered, st.Mark = false, &sourceMark{Cause: MarkSubscription, Why: fmt.Sprintf("its subscription missed the bus's heartbeat for %s", HeartbeatWithin), Since: o.now()}
|
|
marked = append(marked, s)
|
|
}
|
|
}
|
|
return marked
|
|
}
|
|
|
|
// Heard takes an event, or a heartbeat when event is empty, of a source into the model. A source marked from its subscription
|
|
// is asked once, on its return, to rebuild what it missed; that answer clears the mark.
|
|
func (o *Overview) Heard(source, event string, body json.RawMessage) {
|
|
o.mu.Lock()
|
|
defer o.mu.Unlock()
|
|
st := o.state[source]
|
|
if st == nil {
|
|
return
|
|
}
|
|
if st.Mark != nil && st.Mark.Cause == MarkSubscription {
|
|
_ = o.rebuildLocked(source)
|
|
if !st.Answered {
|
|
return
|
|
}
|
|
}
|
|
if st.Mark == nil {
|
|
st.Last = o.now()
|
|
}
|
|
if event == "" {
|
|
return
|
|
}
|
|
var f struct {
|
|
Number int `json:"number"`
|
|
Status string `json:"status"`
|
|
To string `json:"to"`
|
|
State string `json:"state"`
|
|
Title string `json:"title"`
|
|
Design int `json:"design"`
|
|
ID string `json:"id"`
|
|
Group string `json:"group"`
|
|
Branch string `json:"branch"`
|
|
Outcome string `json:"outcome"`
|
|
Issues []int `json:"issues"`
|
|
Members []string `json:"members"`
|
|
Why string `json:"why"`
|
|
Key string `json:"key"`
|
|
Asker string `json:"asker"`
|
|
Headline string `json:"headline"`
|
|
Offered []string `json:"offered_on"`
|
|
Channels []string `json:"channels"`
|
|
}
|
|
if json.Unmarshal(body, &f) != nil {
|
|
return
|
|
}
|
|
name := event[strings.LastIndexByte(event, '.')+1:]
|
|
switch source {
|
|
case SourceTracker:
|
|
switch name {
|
|
case "opened":
|
|
if f.Number > 0 {
|
|
o.issues[f.Number] = trackerRow{Number: f.Number, Title: f.Title, Status: firstNonEmpty(f.Status, "open")}
|
|
}
|
|
case "moved":
|
|
if r, ok := o.issues[f.Number]; ok {
|
|
r.Status = firstNonEmpty(f.To, f.Status, r.Status)
|
|
o.issues[f.Number] = r
|
|
} else if f.Number > 0 {
|
|
o.issues[f.Number] = trackerRow{Number: f.Number, Title: f.Title, Status: firstNonEmpty(f.To, f.Status)}
|
|
}
|
|
case "milestone-set", "milestone-moved":
|
|
k := milestoneKey(f.Design, f.ID)
|
|
m := o.milestones[k]
|
|
m.Design, m.ID = f.Design, f.ID
|
|
m.Outcome, m.Issues = firstNonEmpty(f.Outcome, m.Outcome), append([]int(nil), firstInts(f.Issues, m.Issues)...)
|
|
m.Status = firstNonEmpty(f.To, f.Status, m.Status)
|
|
o.milestones[k] = m
|
|
}
|
|
case SourceDelivery:
|
|
id := firstNonEmpty(f.Group, f.ID)
|
|
switch name {
|
|
case "pull-merged":
|
|
// A merged pull request leaves the group named by its head branch (a group's id is its branch name): it waits for
|
|
// the operator no longer (design 47 G §431).
|
|
delete(o.groups, f.Branch)
|
|
case "transition":
|
|
if g, ok := o.groups[id]; ok {
|
|
g.State = firstNonEmpty(f.To, f.State, g.State)
|
|
g.Why = firstNonEmpty(f.Why, g.Why)
|
|
o.groups[id] = g
|
|
}
|
|
case "group-ended":
|
|
delete(o.groups, id)
|
|
}
|
|
case SourceAsks:
|
|
switch name {
|
|
case "offered":
|
|
k := firstNonEmpty(f.Key, f.Asker+"."+f.ID)
|
|
o.asks[k] = askRow{Key: k, Asker: f.Asker, Headline: f.Headline, Offered: firstStrings(f.Offered, f.Channels)}
|
|
case "closed":
|
|
delete(o.asks, firstNonEmpty(f.Key, f.Asker+"."+f.ID))
|
|
}
|
|
}
|
|
o.rebuilt = o.now()
|
|
}
|
|
|
|
func firstInts(a, b []int) []int {
|
|
if len(a) > 0 {
|
|
return a
|
|
}
|
|
return b
|
|
}
|
|
|
|
func firstStrings(a, b []string) []string {
|
|
if len(a) > 0 {
|
|
return a
|
|
}
|
|
return b
|
|
}
|
|
|
|
// Model is the page's model at the time asked: the ledger's dates are counted now, the other blocks are what the events kept.
|
|
func (o *Overview) Model() (*OverviewModel, error) {
|
|
var entries []*Entry
|
|
if o.Records != nil {
|
|
var err error
|
|
if entries, err = o.Records(); err != nil {
|
|
return nil, fmt.Errorf("the records cannot be read: %v", err)
|
|
}
|
|
}
|
|
o.mu.Lock()
|
|
defer o.mu.Unlock()
|
|
now := o.now()
|
|
m := &OverviewModel{At: now, LastRebuilt: o.rebuilt}
|
|
var ms []trackerMilestone
|
|
for _, t := range o.milestones {
|
|
ms = append(ms, t)
|
|
}
|
|
sort.Slice(ms, func(i, j int) bool {
|
|
if ms[i].Design != ms[j].Design {
|
|
return ms[i].Design < ms[j].Design
|
|
}
|
|
return ms[i].ID < ms[j].ID
|
|
})
|
|
for _, t := range ms {
|
|
if t.Status == "done" || t.Status == "dropped" {
|
|
continue
|
|
}
|
|
row := MilestoneRow{Design: t.Design, ID: t.ID, Outcome: t.Outcome, Status: t.Status, Issues: []IssueRow{}}
|
|
for _, n := range t.Issues {
|
|
i := o.issues[n]
|
|
row.Issues = append(row.Issues, IssueRow{Number: n, Title: i.Title, Status: i.Status})
|
|
}
|
|
m.Designs = append(m.Designs, row)
|
|
}
|
|
cutoff := now.Add(-RecentFor)
|
|
for _, e := range entries {
|
|
r := e.Working()
|
|
row := RecordRow{Record: r.Key().String(), Title: r.Title, Status: r.Status, Date: r.Date}
|
|
switch {
|
|
case e.Current.Accepted() && acceptedAt(e.Current).After(cutoff):
|
|
m.Accepted = append(m.Accepted, row)
|
|
case r.Status == StatusProposed:
|
|
if r.NeedsOperator && r.Ask != nil && r.Ask.Said == "waiting" {
|
|
row.Waits = "the operator's Approve"
|
|
m.Operator.Records = append(m.Operator.Records, row)
|
|
} else if r.Judging != nil && r.Judging.State == JudgingFailed {
|
|
row.Waits = "its review, which could not be had"
|
|
} else {
|
|
row.Waits = "its review"
|
|
}
|
|
m.Waiting = append(m.Waiting, row)
|
|
}
|
|
}
|
|
sort.Slice(m.Accepted, func(i, j int) bool { return m.Accepted[i].Record > m.Accepted[j].Record })
|
|
for _, a := range o.asks {
|
|
m.Operator.Asks = append(m.Operator.Asks, a)
|
|
}
|
|
sort.Slice(m.Operator.Asks, func(i, j int) bool { return m.Operator.Asks[i].Key < m.Operator.Asks[j].Key })
|
|
for _, g := range o.groups {
|
|
if g.State != "merged" {
|
|
m.Operator.Groups = append(m.Operator.Groups, g)
|
|
}
|
|
}
|
|
sort.Slice(m.Operator.Groups, func(i, j int) bool { return m.Operator.Groups[i].ID < m.Operator.Groups[j].ID })
|
|
for _, n := range o.sourceNames() {
|
|
m.Sources = append(m.Sources, *o.state[n])
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
// acceptedAt is when a record was accepted here, or its day for one imported accepted.
|
|
func acceptedAt(r *Record) time.Time {
|
|
if !r.AcceptedAt.IsZero() {
|
|
return r.AcceptedAt
|
|
}
|
|
t, _ := time.Parse("2006-01-02", r.Date)
|
|
return t
|
|
}
|
|
|
|
// Unanswered are the sources marked, each with its cause, for the module's health: degraded while any is.
|
|
func (o *Overview) Unanswered() []SourceState {
|
|
o.mu.Lock()
|
|
defer o.mu.Unlock()
|
|
var out []SourceState
|
|
for _, n := range o.sourceNames() {
|
|
if !o.state[n].Answered {
|
|
out = append(out, *o.state[n])
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Calls is how many times a verb was asked.
|
|
func (o *Overview) Calls(verb string) int {
|
|
o.mu.Lock()
|
|
defer o.mu.Unlock()
|
|
return o.verbCalls[verb]
|
|
}
|
|
|
|
// Beat takes the runtime's word on the module's consumer between events (stdio.OnHeartbeat): one consumer carries every
|
|
// subscription, so a beat is heard by every source the page subscribes to, and a missed heartbeat marks each of them
|
|
// from its subscription at once, with the runtime's why, instead of waiting out HeartbeatWithin.
|
|
func (o *Overview) Beat(h stdio.Heartbeat) {
|
|
sources := make([]string, 0, len(OverviewEvents))
|
|
for s := range OverviewEvents {
|
|
sources = append(sources, s)
|
|
}
|
|
sort.Strings(sources)
|
|
if !h.Missed {
|
|
for _, s := range sources {
|
|
o.Heard(s, "", nil)
|
|
}
|
|
return
|
|
}
|
|
why := "its subscription's consumer missed the bus's heartbeat"
|
|
if h.Why != "" {
|
|
why += ": " + h.Why
|
|
}
|
|
o.mu.Lock()
|
|
defer o.mu.Unlock()
|
|
for _, s := range sources {
|
|
st := o.state[s]
|
|
switch {
|
|
case st == nil:
|
|
case st.Mark == nil:
|
|
st.Answered, st.Mark = false, &sourceMark{Cause: MarkSubscription, Why: why, Since: o.now()}
|
|
case st.Mark.Cause == MarkSubscription:
|
|
st.Mark.Why = why
|
|
}
|
|
}
|
|
}
|