diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/manifest_test.go b/modules/mesh-knowledge/cmd/mesh-knowledge/manifest_test.go index 26caed928..42e25504e 100644 --- a/modules/mesh-knowledge/cmd/mesh-knowledge/manifest_test.go +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/manifest_test.go @@ -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 +} diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/overview.go b/modules/mesh-knowledge/cmd/mesh-knowledge/overview.go new file mode 100644 index 000000000..c1ffea5e9 --- /dev/null +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/overview.go @@ -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] +} diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/overview_test.go b/modules/mesh-knowledge/cmd/mesh-knowledge/overview_test.go new file mode 100644 index 000000000..f6c090822 --- /dev/null +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/overview_test.go @@ -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) + } +} diff --git a/modules/mesh-knowledge/module.json b/modules/mesh-knowledge/module.json index 650b57383..a076c11e6 100644 --- a/modules/mesh-knowledge/module.json +++ b/modules/mesh-knowledge/module.json @@ -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": {