CAT-P1: the overview page's model: three blocks from the ledger and three sources' verbs and events

The model is built once at start from the issue tracker's milestones and list, the delivery groups and the operator channel's asks, and
kept in memory from their events (group, transition, merged; offered, closed; opened, moved, milestone-set, milestone-moved), asking no verb
again; the records come from the ledger and the seven days are counted when the answer is given. The manifest invokes groups and asks,
consumes those events, has no reads and no decided family.

Issue 1968.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
This commit is contained in:
2026-11-04 12:28:32 +01:00
parent a3a11842bf
commit 40b1307839
4 changed files with 650 additions and 5 deletions
@@ -79,16 +79,16 @@ func TestItDeclaresAndHoldsTheKnowledgeBaseSeatOnceForTheMesh(t *testing.T) {
t.Fatalf("requires %s, contributes %v, state %v", m.Requires, m.Contributes, m.State)
}
// It asks the operator on the router, hears the router's word, and hears the judge's answers.
if !reflect.DeepEqual(m.Uses, []string{"operator-channel"}) || !reflect.DeepEqual(m.Consumes, []string{"claude-code.judged"}) {
if !reflect.DeepEqual(m.Uses, []string{"operator-channel"}) || !reflect.DeepEqual(m.Consumes, append([]string{"claude-code.judged"}, overviewConsumes()...)) {
t.Fatalf("uses %v, consumes %v", m.Uses, m.Consumes)
}
if !reflect.DeepEqual(m.Invokes, []string{"seat:issue-tracker.reserve", "claude-code.claude_code_judge", "claude-code.claude_code_judged", "records.records_status",
"records.records_list", "records.records_read", "seat:operator-channel.notify", "seat:issue-tracker.list", "seat:issue-tracker.get",
"seat:issue-tracker.milestones", "seat:issue-tracker.changes", "seat:mesh-delivery.deliveries", "seat:mesh-delivery.show", "seat:mesh-delivery.groups"}) {
"seat:issue-tracker.milestones", "seat:issue-tracker.changes", "seat:mesh-delivery.deliveries", "seat:mesh-delivery.show", "seat:mesh-delivery.groups", "seat:operator-channel.asks"}) {
t.Fatalf("invokes %v", m.Invokes)
}
src := ""
for _, f := range []string{"main.go", "hq.go", "progress.go", "progress_since.go", "milestones.go"} {
for _, f := range []string{"main.go", "hq.go", "progress.go", "progress_since.go", "milestones.go", "overview.go"} {
src += readFile(t, f)
}
for _, inv := range m.Invokes {
@@ -269,7 +269,21 @@ func TestItAsksForItsBusStateInTheTypedForm(t *testing.T) {
if !slices.Contains(catalogue.TerminalKeys(m), "readers") {
t.Fatalf("readers is not a terminal key (%v): the grant names a value only the controller's terminal or the operator's Approve sets", catalogue.TerminalKeys(m))
}
for _, c := range m.Consumes {
if strings.Contains(c, "decided") {
t.Errorf("it consumes %s: an asker's decided family is read by its asker only (ADR 0616 §2)", c)
}
}
if len(m.Reads) != 0 {
t.Fatalf("it reads %v, and no module reads another's bus state (ADR 0596 §2)", m.Reads)
}
}
// overviewConsumes are the events the overview page hears, in the order the manifest lists them.
func overviewConsumes() []string {
var out []string
for _, s := range []string{SourceTracker, SourceDelivery, SourceAsks} {
out = append(out, OverviewEvents[s]...)
}
return out
}
@@ -0,0 +1,520 @@
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"
)
// 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: {"mesh-delivery-gate.group", "mesh-delivery-gate.transition", "mesh-delivery-gate.merged"},
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"`
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 "group":
g := o.groups[id]
g.ID, g.State, g.Why = id, firstNonEmpty(f.State, f.To, g.State), firstNonEmpty(f.Why, g.Why)
g.Members = firstStrings(f.Members, g.Members)
o.groups[id] = g
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 "merged":
delete(o.groups, id)
}
case SourceAsks:
switch name {
case "offered":
o.asks[f.Key] = askRow{Key: f.Key, Asker: f.Asker, Headline: f.Headline, Offered: firstStrings(f.Offered, f.Channels)}
case "closed":
delete(o.asks, f.Key)
}
}
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]
}
@@ -0,0 +1,101 @@
package main
import (
"encoding/json"
"errors"
"testing"
"time"
)
// overviewWorld is a page model over the sources played here: what each verb answers, and what fails.
type overviewWorld struct {
o *Overview
now time.Time
answers map[string]string
fail map[string]bool
entries []*Entry
}
func newOverviewWorld(t *testing.T) *overviewWorld {
t.Helper()
w := &overviewWorld{now: time.Date(2026, 11, 4, 12, 0, 0, 0, time.UTC), fail: map[string]bool{}, answers: map[string]string{
TrackerMilestonesVerb: `{"milestones":[{"design":47,"id":"1","outcome":"A merge is never dropped","status":"open","issues":[973,525]},{"design":47,"id":"2","outcome":"Done thing","status":"done","issues":[1]}]}`,
TrackerListVerb: `[{"number":973,"title":"The window","status":"fixing"},{"number":525,"title":"The queue","status":"open"}]`,
DeliveryGroupsVerb: `[{"id":"fix/a","state":"held","why":"a member is held","members":["x@1"]}]`,
AsksVerb: `{"asks":[{"key":"ask-1","asker":"mesh-knowledge","headline":"Approve ADR 0301","state":"open","offered_on":["telegram"]},{"key":"ask-0","asker":"x","headline":"old","state":"answered"}]}`,
}}
w.o = NewOverview(func(key string, body any) (json.RawMessage, error) {
if w.fail[key] {
return nil, errors.New("no answer in time")
}
return json.RawMessage(w.answers[key]), nil
}, func() ([]*Entry, error) { return w.entries, nil }, func() time.Time { return w.now })
return w
}
func recordEntry(n int, status, date string, accepted time.Time, mut func(*Record)) *Entry {
r := &Record{Kind: KindDecision, Number: n, Title: "Record " + itoa(n), Status: status, Date: date, AcceptedAt: accepted}
if mut != nil {
mut(r)
}
return &Entry{Current: r, CurrentVersion: 1, Versions: 1}
}
// The page's model (ADR 0616 §1-3, AC P1): three blocks built from the ledger, the tracker's milestones and issues, the delivery
// groups and the operator channel's asks; events change the blocks and ask no verb; a record accepted eight days before the
// answer is not in the seven-day block, with no event between.
func TestThePageModelHasThreeBlocksKeptByEventsAndAsksNoVerbAgain(t *testing.T) {
w := newOverviewWorld(t)
w.entries = []*Entry{
recordEntry(301, StatusAccepted, "2026-11-02", w.now.Add(-2*24*time.Hour), nil),
recordEntry(302, StatusAccepted, "2026-10-20", w.now.Add(-6*24*time.Hour-23*time.Hour), nil),
recordEntry(303, StatusProposed, "2026-11-04", time.Time{}, func(r *Record) {
r.NeedsOperator = true
r.Ask = &OperatorAsk{ID: "a", Said: "waiting"}
}),
recordEntry(304, StatusProposed, "2026-11-04", time.Time{}, nil),
}
w.o.Start()
m, err := w.o.Model()
if err != nil {
t.Fatal(err)
}
if len(m.Designs) != 1 || m.Designs[0].ID != "1" || len(m.Designs[0].Issues) != 2 || m.Designs[0].Issues[0].Title != "The window" {
t.Fatalf("the design block: %+v", m.Designs)
}
if len(m.Accepted) != 2 || len(m.Waiting) != 2 || len(m.Operator.Records) != 1 || m.Operator.Records[0].Record != "ADR 0303" ||
len(m.Operator.Asks) != 1 || m.Operator.Asks[0].Key != "ask-1" || len(m.Operator.Groups) != 1 {
t.Fatalf("%+v", m)
}
asked := map[string]int{}
for _, v := range []string{TrackerMilestonesVerb, TrackerListVerb, DeliveryGroupsVerb, AsksVerb} {
asked[v] = w.o.Calls(v)
}
// Events change the blocks.
w.o.Heard(SourceDelivery, "mesh-delivery-gate.group", json.RawMessage(`{"group":"fix/b","state":"checking","members":["y@2"]}`))
w.o.Heard(SourceDelivery, "mesh-delivery-gate.transition", json.RawMessage(`{"group":"fix/a","to":"approved"}`))
w.o.Heard(SourceAsks, "messenger.offered", json.RawMessage(`{"key":"ask-2","asker":"mesh-delivery","headline":"Approve the group","channels":["telegram"]}`))
w.o.Heard(SourceTracker, "mesh-issues.opened", json.RawMessage(`{"number":1999,"title":"New","status":"open"}`))
w.o.Heard(SourceTracker, "mesh-issues.milestone-moved", json.RawMessage(`{"design":47,"id":"1","to":"done","issues":[973,525,1999]}`))
m, _ = w.o.Model()
if len(m.Operator.Groups) != 2 || m.Operator.Groups[0].State != "approved" || len(m.Operator.Asks) != 2 || len(m.Designs) != 0 {
t.Fatalf("after the events: %+v", m)
}
w.o.Heard(SourceDelivery, "mesh-delivery-gate.merged", json.RawMessage(`{"group":"fix/a"}`))
w.o.Heard(SourceAsks, "messenger.closed", json.RawMessage(`{"key":"ask-1","how":"answered"}`))
m, _ = w.o.Model()
if len(m.Operator.Groups) != 1 || m.Operator.Groups[0].ID != "fix/b" || len(m.Operator.Asks) != 1 || m.Operator.Asks[0].Key != "ask-2" {
t.Fatalf("after merged and closed: %+v", m.Operator)
}
for v, n := range asked {
if w.o.Calls(v) != n {
t.Errorf("%s was asked again by an event", v)
}
}
// The seven days are counted when the answer is given: no event between, the record falls out of the block.
w.now = w.now.Add(2 * 24 * time.Hour)
m, _ = w.o.Model()
if len(m.Accepted) != 1 || m.Accepted[0].Record != "ADR 0301" {
t.Fatalf("the seven-day block: %+v", m.Accepted)
}
}
+12 -2
View File
@@ -189,7 +189,16 @@
"formed"
],
"consumes": [
"claude-code.judged"
"claude-code.judged",
"mesh-issues.opened",
"mesh-issues.moved",
"mesh-issues.milestone-set",
"mesh-issues.milestone-moved",
"mesh-delivery-gate.group",
"mesh-delivery-gate.transition",
"mesh-delivery-gate.merged",
"messenger.offered",
"messenger.closed"
],
"uses": [
"operator-channel"
@@ -208,7 +217,8 @@
"seat:issue-tracker.changes",
"seat:mesh-delivery.deliveries",
"seat:mesh-delivery.show",
"seat:mesh-delivery.groups"
"seat:mesh-delivery.groups",
"seat:operator-channel.asks"
],
"config": {
"readers": {