diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/health.go b/modules/mesh-knowledge/cmd/mesh-knowledge/health.go index 92d03c5b..7cb0c44a 100644 --- a/modules/mesh-knowledge/cmd/mesh-knowledge/health.go +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/health.go @@ -208,3 +208,28 @@ func (k *KB) formRefused() int { } return n } + +// pageHealth adds the overview page to a verdict: the last rebuild and each source's last answer, and the module is said degraded +// while any source is unanswered. The module stays healthy: a source that does not answer is that module's fault, not this one's. +func (k *KB) pageHealth(base HealthAnswer) HealthAnswer { + if k.Overview == nil { + return base + } + k.Overview.mu.Lock() + rebuilt := k.Overview.rebuilt + k.Overview.mu.Unlock() + if bad := k.Overview.Unanswered(); len(bad) > 0 { + var says []string + for _, s := range bad { + why := "no answer yet" + if s.Mark != nil { + why = s.Mark.Cause + ": " + s.Mark.Why + } + says = append(says, fmt.Sprintf("%s (%s; last answer %s)", s.Name, why, s.Last.Local().Format("15:04"))) + } + base.Why += "; the overview page is degraded, it does not answer: " + strings.Join(says, ", ") + return base + } + base.Why += "; the overview page was last rebuilt " + rebuilt.Local().Format("2006-01-02 15:04") + return base +} diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/main.go b/modules/mesh-knowledge/cmd/mesh-knowledge/main.go index 9c0940b1..e2c366d6 100644 --- a/modules/mesh-knowledge/cmd/mesh-knowledge/main.go +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/main.go @@ -628,7 +628,7 @@ func healthTool(status string, now func() time.Time, k *KB) stdio.Tool { Run: func(map[string]any) (any, error) { ans := ReadStatus(status, now()) if k != nil { - ans = k.ledgerHealth(ans) // once the records are on the ledger, what the ledger form owes (health.go) + ans = k.pageHealth(k.ledgerHealth(ans)) // what the ledger form owes once the records are on it, and the page (health.go) } return ans, nil }} diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/manifest_test.go b/modules/mesh-knowledge/cmd/mesh-knowledge/manifest_test.go index 42e25504..724672f4 100644 --- a/modules/mesh-knowledge/cmd/mesh-knowledge/manifest_test.go +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/manifest_test.go @@ -84,11 +84,11 @@ func TestItDeclaresAndHoldsTheKnowledgeBaseSeatOnceForTheMesh(t *testing.T) { } 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:operator-channel.asks"}) { + "seat:issue-tracker.milestones", "seat:issue-tracker.changes", "seat:mesh-delivery.deliveries", "seat:mesh-delivery.show", "seat:mesh-delivery.groups", "seat:operator-channel.asks", "seat:mesh-hand-acts.hand-act"}) { t.Fatalf("invokes %v", m.Invokes) } src := "" - for _, f := range []string{"main.go", "hq.go", "progress.go", "progress_since.go", "milestones.go", "overview.go"} { + for _, f := range []string{"main.go", "hq.go", "progress.go", "progress_since.go", "milestones.go", "overview.go", "tools.go"} { src += readFile(t, f) } for _, inv := range m.Invokes { diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/overview_test.go b/modules/mesh-knowledge/cmd/mesh-knowledge/overview_test.go index f6c09082..b595505c 100644 --- a/modules/mesh-knowledge/cmd/mesh-knowledge/overview_test.go +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/overview_test.go @@ -3,6 +3,8 @@ package main import ( "encoding/json" "errors" + "reflect" + "strings" "testing" "time" ) @@ -99,3 +101,117 @@ func TestThePageModelHasThreeBlocksKeptByEventsAndAsksNoVerbAgain(t *testing.T) t.Fatalf("the seven-day block: %+v", m.Accepted) } } + +// A source that does not answer says so, never as an empty list (ADR 0616 §5, §6; AC P3): from its verb, on a bounded backoff +// asked again alone, its first answer clearing the mark; from its subscription, asking nothing while it is silent and the verb once on its +// next event; the model as a tool equals the page's model; the re-read tool asks one source and records the hand act. +func TestASourceThatDoesNotAnswerIsSaidAndAskedAgainOnABackoff(t *testing.T) { + w := newOverviewWorld(t) + w.fail[DeliveryGroupsVerb] = true + w.o.Start() + m, _ := w.o.Model() + var delivery SourceState + for _, s := range m.Sources { + if s.Name == SourceDelivery { + delivery = s + } + } + if delivery.Answered || delivery.Mark == nil || delivery.Mark.Cause != MarkVerb || len(m.Operator.Groups) != 0 || !strings.Contains(delivery.Mark.Why, "no answer in time") { + t.Fatalf("%+v", delivery) + } + if len(m.Designs) != 1 { + t.Fatalf("another source's block was lost: %+v", m.Designs) + } + // Asked again on the backoff alone: 10 s, then doubling, at most 5 minutes; no other source is asked. + calls := w.o.Calls(DeliveryGroupsVerb) + if got := w.o.Due(); len(got) != 0 { + t.Fatalf("asked before the backoff ran out: %v", got) + } + var waits []time.Duration + for i := 0; i < 8; i++ { + before := w.now + w.now = w.o.state[SourceDelivery].Mark.Next + waits = append(waits, w.now.Sub(before)) + if got := w.o.Due(); len(got) != 1 || got[0] != SourceDelivery { + t.Fatalf("%v", got) + } + } + if waits[0] != BackoffFrom || waits[1] != 2*BackoffFrom || waits[7] != BackoffMost || w.o.Calls(DeliveryGroupsVerb) != calls+8 || w.o.Calls(AsksVerb) != 1 { + t.Fatalf("waits %v, delivery asked %d, asks asked %d", waits, w.o.Calls(DeliveryGroupsVerb), w.o.Calls(AsksVerb)) + } + // Its first answer rebuilds the block and clears the mark. + w.fail[DeliveryGroupsVerb] = false + w.now = w.o.state[SourceDelivery].Mark.Next + w.o.Due() + m, _ = w.o.Model() + if len(w.o.Unanswered()) != 0 || len(m.Operator.Groups) != 1 { + t.Fatalf("%+v", m) + } + // A subscription that goes silent marks the source from the subscription and asks nothing while it is silent. + asked := w.o.Calls(AsksVerb) + w.now = w.now.Add(HeartbeatWithin + time.Second) + if got := w.o.Silent(); len(got) != 3 { + t.Fatalf("%v", got) + } + if got := w.o.Due(); len(got) != 0 || w.o.Calls(AsksVerb) != asked { + t.Fatalf("a silent subscription was asked: %v", got) + } + if u := w.o.Unanswered(); len(u) != 3 || u[0].Mark.Cause != MarkSubscription { + t.Fatalf("%+v", u) + } + // Its next event (or heartbeat) makes the module ask the verb once, and that answer clears the mark. + w.o.Heard(SourceAsks, "messenger.offered", json.RawMessage(`{"key":"ask-9","headline":"x"}`)) + if w.o.Calls(AsksVerb) != asked+1 || w.o.state[SourceAsks].Mark != nil { + t.Fatalf("asked %d, mark %+v", w.o.Calls(AsksVerb), w.o.state[SourceAsks].Mark) + } + // An unanswered verb then turns the mark into one from the verb. + w.fail[DeliveryGroupsVerb] = true + w.o.Heard(SourceDelivery, "", nil) + if mk := w.o.state[SourceDelivery].Mark; mk == nil || mk.Cause != MarkVerb { + t.Fatalf("%+v", mk) + } +} + +func TestThePagesModelIsAToolAndItsRereadIsAHandAct(t *testing.T) { + w := newOverviewWorld(t) + w.entries = []*Entry{recordEntry(301, StatusAccepted, "2026-11-02", w.now.Add(-24*time.Hour), nil)} + w.o.Start() + var acts []string + k := &KB{Overview: w.o, Seats: func(key string, body any) (json.RawMessage, error) { + acts = append(acts, key) + return json.RawMessage(`{}`), nil + }, Now: func() time.Time { return w.now }} + var tools []toolRun + for _, tool := range moduleTools(k, callerOf) { + tools = append(tools, toolRun{tool.Name, tool.Run}) + } + got, err := callTool(t, tools, OverviewTool, map[string]any{}) + want, _ := w.o.Model() + raw, _ := json.Marshal(want) + var wantMap map[string]any + _ = json.Unmarshal(raw, &wantMap) + if err != nil || !reflect.DeepEqual(got, wantMap) { + t.Fatalf("the tool's answer is not the page's model: %v", err) + } + // The re-read asks the one source and no other, and records itself. + before := map[string]int{} + for _, v := range []string{TrackerListVerb, DeliveryGroupsVerb, AsksVerb} { + before[v] = w.o.Calls(v) + } + w.fail[AsksVerb] = true + res, err := callTool(t, tools, RereadTool, map[string]any{"source": SourceAsks, "why": "the page showed it stale"}) + if err != nil || res["answered"] != false || w.o.Calls(AsksVerb) != before[AsksVerb]+1 || w.o.Calls(DeliveryGroupsVerb) != before[DeliveryGroupsVerb] || w.o.Calls(TrackerListVerb) != before[TrackerListVerb] { + t.Fatalf("%v %v", err, res) + } + if len(acts) != 1 || acts[0] != "seat:mesh-hand-acts.hand-act" { + t.Fatalf("the hand act: %v", acts) + } + if _, err := callTool(t, tools, RereadTool, map[string]any{"source": "nobody"}); err == nil { + t.Fatal("a source the page lacks was re-read") + } + // The health carries the page: degraded while a source is unanswered, healthy. + h := k.pageHealth(HealthAnswer{State: "healthy", Healthy: true, Why: "ok"}) + if !h.Healthy || !strings.Contains(h.Why, "overview page is degraded") || !strings.Contains(h.Why, SourceAsks) { + t.Fatalf("%+v", h) + } +} diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/tools.go b/modules/mesh-knowledge/cmd/mesh-knowledge/tools.go index 5ec1ec22..df27bb07 100644 --- a/modules/mesh-knowledge/cmd/mesh-knowledge/tools.go +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/tools.go @@ -29,11 +29,13 @@ const ( IntegrityTool = "knowledge_integrity" MoveTool = "knowledge_move" MoveCheckTool = "knowledge_move_check" + OverviewTool = "knowledge_overview" + RereadTool = "knowledge_overview_reread" ) // ModuleTools are the module's own tools beside the health tool, in the order the manifest lists them. var ModuleTools = []string{HealthTool, HighestTool, MarksTool, HeadTool, OperationsTool, SectionTool, DraftTool, PartsTool, - AskedTool, FillTool, IndexTool, AgainTool, IntegrityTool, FormTool, MoveTool, MoveCheckTool} + AskedTool, FillTool, IndexTool, AgainTool, IntegrityTool, FormTool, MoveTool, MoveCheckTool, OverviewTool, RereadTool} var errNotOnLedger = errors.New("the records are still in the database: this tool reads the ledger, which serves them after the move") @@ -373,6 +375,53 @@ func moduleTools(k *KB, caller func(map[string]any) string) []stdio.Tool { } return ms.Move(context.Background(), ask, func(s string) { k.logf("[mesh-knowledge] the move: %s", s) }) }}, + {Name: OverviewTool, + Description: "The overview page's model as data, the same the page renders: the designs in progress with their milestones and issues, the records " + + "accepted in the last seven days and those waiting, what waits on the operator (records for an Approve, open asks, delivery groups), and each source " + + "with whether it answered, why it did not, and when it last did. Reads only.", + Input: schema(nil, map[string]any{}), + Run: func(m map[string]any) (any, error) { + if _, err := readArgs(OverviewTool, m); err != nil { + return nil, err + } + if k.Overview == nil { + return nil, errors.New("the overview page is not running in this process") + } + return k.Overview.Model() + }}, + {Name: RereadTool, + Description: "Ask one source's verb again by hand and rebuild its block of the overview page now, asking no other: issue-tracker, mesh-delivery or " + + "operator-channel. A hand act, recorded with the hand-act log. Answers whether the source answered.", + Input: schema([]string{"source"}, map[string]any{"source": text("issue-tracker, mesh-delivery or operator-channel"), "why": text("one line: why")}), + Run: func(m map[string]any) (any, error) { + a, err := readArgs(RereadTool, m, "source", "why") + if err != nil { + return nil, err + } + source, err := a.str("source") + if err != nil { + return nil, err + } + why, _ := a.str("why") + if k.Overview == nil { + return nil, errors.New("the overview page is not running in this process") + } + if _, ok := k.Overview.state[source]; !ok { + return nil, fmt.Errorf("%q is no source of the page: issue-tracker, mesh-delivery or operator-channel", source) + } + rebuildErr := k.Overview.Rebuild(source) + if k.Seats != nil { + if _, err := k.Seats("seat:mesh-hand-acts.hand-act", map[string]any{"what": "asked the " + source + " again for the overview page, by hand", + "why": firstNonEmpty(why, "the page showed the source unanswered, or stale")}); err != nil { + k.logf("[mesh-knowledge] the hand act of the re-read of %s could not be recorded: %v", source, err) + } + } + out := map[string]any{"source": source, "answered": rebuildErr == nil} + if rebuildErr != nil { + out["why"] = rebuildErr.Error() + } + return out, nil + }}, {Name: MoveCheckTool, Description: "Compare the database with the ledger record by record now: the versions, the served and the proposed version whole and every section's text. " + "Answers every difference, at most 50, and where the records are served from; a database that is behind is named.", diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/tools_test.go b/modules/mesh-knowledge/cmd/mesh-knowledge/tools_test.go index f4566178..e09efe24 100644 --- a/modules/mesh-knowledge/cmd/mesh-knowledge/tools_test.go +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/tools_test.go @@ -130,7 +130,7 @@ func TestEveryStateTheModuleKeepsHasAToolThatReadsIt(t *testing.T) { if _, err := callTool(t, tools, MoveCheckTool, map[string]any{}); err == nil { t.Fatal("a move check without a database") } - if len(ModuleTools) != 16 { + if len(ModuleTools) != 18 { t.Fatalf("%v", ModuleTools) } } diff --git a/modules/mesh-knowledge/module.json b/modules/mesh-knowledge/module.json index a047831c..1da1278c 100644 --- a/modules/mesh-knowledge/module.json +++ b/modules/mesh-knowledge/module.json @@ -179,7 +179,9 @@ "knowledge_integrity", "knowledge_form_only", "knowledge_move", - "knowledge_move_check" + "knowledge_move_check", + "knowledge_overview", + "knowledge_overview_reread" ], "emits": [ "reserved", @@ -233,7 +235,8 @@ "seat:mesh-delivery.deliveries", "seat:mesh-delivery.show", "seat:mesh-delivery.groups", - "seat:operator-channel.asks" + "seat:operator-channel.asks", + "seat:mesh-hand-acts.hand-act" ], "config": { "readers": {