diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/draft.go b/modules/mesh-knowledge/cmd/mesh-knowledge/draft.go index 4bcbe82e..7e1bc2bb 100644 --- a/modules/mesh-knowledge/cmd/mesh-knowledge/draft.go +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/draft.go @@ -356,7 +356,7 @@ func (k *KB) tellTopic(ctx context.Context, topic string) { summary := fmt.Sprintf("The clauses of %d prose decisions on %s are drafted and checked: %d confirmed, %d still drafted.", records, topic, confirmed, drafted) explanation := "A second judgement confirmed each clause against its record; a clause still drafted is never served as in force. " + "You may send any record back with the knowledge base's return, and its clauses are drafted again. Nothing needs you otherwise." - if err := k.Notify("knowledge.clauses."+strings.ReplaceAll(topic, " ", "-"), summary, "Clauses drafted for "+topic, explanation); err != nil { + if err := k.Notify("knowledge.clauses."+strings.ReplaceAll(topic, " ", "-"), summary, "Clauses drafted for "+topic, explanation, ""); err != nil { k.logf("[mesh-knowledge] the operator could not be told about %s's clauses: %v", topic, err) } } diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/kb.go b/modules/mesh-knowledge/cmd/mesh-knowledge/kb.go index a99f8747..9e51e68d 100644 --- a/modules/mesh-knowledge/cmd/mesh-knowledge/kb.go +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/kb.go @@ -64,8 +64,9 @@ type Reserver func(kind, title string) (int, error) // the structured answer and who judged. type Judgement func(question string, schema map[string]any, model string) (json.RawMessage, string, error) -// Notifier tells the operator's channel something (operator-channel.notify). -type Notifier func(key, summary, headline, explanation string) error +// Notifier tells the operator's channel something (operator-channel.notify). Resolved is the line its message says +// once it is cleared: the channel keeps the one given when it is raised (novox/hq issue 497); "" for none. +type Notifier func(key, summary, headline, explanation, resolved string) error // Clearer tells the operator's channel that what it was told under key is over, in one resolved line. type Clearer func(key, resolved string) error diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/kit_test.go b/modules/mesh-knowledge/cmd/mesh-knowledge/kit_test.go index 5247479a..1d0ad616 100644 --- a/modules/mesh-knowledge/cmd/mesh-knowledge/kit_test.go +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/kit_test.go @@ -400,6 +400,7 @@ type world struct { // toldKeys are the keys told under, and cleared what was cleared: key, then its resolved line. toldKeys []string cleared [][2]string + resolved []string // the resolved line given with each notice raised clock time.Time } @@ -410,8 +411,8 @@ func newWorld(t *testing.T, s Store, records Records) *world { w.k = &KB{Store: s, Records: records, Reserve: w.tracker.reserve, Judge: w.judge.judge, Asks: w.router, Emit: w.said.emit, Settings: func() (Settings, error) { return Settings{}, nil }, Now: func() time.Time { return w.clock }, Logf: func(f string, a ...any) { t.Logf(f, a...) }, - Notify: func(key, summary, headline, explanation string) error { - w.told, w.toldKeys = append(w.told, summary), append(w.toldKeys, key) + Notify: func(key, summary, headline, explanation, resolved string) error { + w.told, w.toldKeys, w.resolved = append(w.told, summary), append(w.toldKeys, key), append(w.resolved, resolved) return nil }, Clear: func(key, resolved string) error { w.cleared = append(w.cleared, [2]string{key, resolved}); return nil }} diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/main.go b/modules/mesh-knowledge/cmd/mesh-knowledge/main.go index 353db2b0..b2836798 100644 --- a/modules/mesh-knowledge/cmd/mesh-knowledge/main.go +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/main.go @@ -225,9 +225,13 @@ func busJudgement(node string) Judgement { } // busNotifier tells the operator's channel, as a warning: it is the one severity below urgent. -func busNotifier(key, summary, headline, explanation string) error { - _, err := stdio.Ask("seat:operator-channel.notify", map[string]any{"key": key, "subject": "the knowledge base", - "severity": "warning", "summary": summary, "headline": headline, "explanation": explanation}) +func busNotifier(key, summary, headline, explanation, resolved string) error { + body := map[string]any{"key": key, "subject": "the knowledge base", + "severity": "warning", "summary": summary, "headline": headline, "explanation": explanation} + if resolved != "" { + body["resolved"] = resolved + } + _, err := stdio.Ask("seat:operator-channel.notify", body) return err } diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/review.go b/modules/mesh-knowledge/cmd/mesh-knowledge/review.go index f6479d2d..4a58c4ce 100644 --- a/modules/mesh-knowledge/cmd/mesh-knowledge/review.go +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/review.go @@ -335,7 +335,9 @@ func (k *KB) tellUnreviewed(e *Entry) { w.Key(), w.Judging.Tries, w.Judging.Reason) explanation := "No review that could not be had counts as an approval. Its author proposes it again to have it reviewed again; " + "until then the knowledge base's health names it. The knowledge base's get and history show it." - if err := k.Notify(key, summary, "A proposal was never reviewed: "+w.Key().String(), explanation); err != nil { + // The channel edits a cleared message with the resolved line given here, not the one sent with the clear. + resolved := w.Key().String() + " no longer waits for its review" + if err := k.Notify(key, summary, "A proposal was never reviewed: "+w.Key().String(), explanation, resolved); err != nil { k.logf("[mesh-knowledge] the operator could not be told that %s was never reviewed: %v; the module's health still names it", w.Key(), err) } } diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/review_owed_test.go b/modules/mesh-knowledge/cmd/mesh-knowledge/review_owed_test.go index d07ce3ea..c094a35f 100644 --- a/modules/mesh-knowledge/cmd/mesh-knowledge/review_owed_test.go +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/review_owed_test.go @@ -2,6 +2,7 @@ package main import ( "context" + "errors" "fmt" "strings" "testing" @@ -14,6 +15,7 @@ import ( // loss; and after its last try the history says so and the operator is told once. It is never an approval. func TestAReviewOwedOutlivesARestartAndItsLastTryIsSaid(t *testing.T) { eachStore(t, func(t *testing.T, s Store) { + inZone(t) w := imported(t, s) n := proposed(t, w, "author@g14", false) key := Key{KindDecision, n} @@ -51,6 +53,9 @@ func TestAReviewOwedOutlivesARestartAndItsLastTryIsSaid(t *testing.T) { if try < MostReviewTries { due = w.clock.Add(time.Duration(try) * 10 * time.Minute) when := "asked again at " + due.Local().Format("2006-01-02 15:04 MST") + if utc := due.UTC().Format("15:04"); strings.Contains(when, utc) || !strings.Contains(when, "CEST") { + t.Fatalf("the time is not said locally: %q (UTC %s)", when, utc) + } if !strings.Contains(why, when) { t.Fatalf("try %d: the history does not say when it is asked again (%q): %q", try, when, why) } @@ -107,7 +112,8 @@ func TestProposedAgainAfterTheLastTryClearsTheNotice(t *testing.T) { runOut(1) e := mustGet(t, s, key) first := fmt.Sprintf("knowledge.unreviewed.decision-%d.v%d", n, e.CurrentVersion) - if len(w.toldKeys) != 1 || w.toldKeys[0] != first || len(w.cleared) != 0 { + if len(w.toldKeys) != 1 || w.toldKeys[0] != first || len(w.cleared) != 0 || + w.resolved[0] != key.String()+" no longer waits for its review" { t.Fatalf("after the last try: told %v, cleared %v", w.toldKeys, w.cleared) } // Another version written while it waits (as a link change on it writes one) tells nothing again. @@ -148,3 +154,46 @@ func TestProposedAgainAfterTheLastTryClearsTheNotice(t *testing.T) { } }) } + +// inZone sets the local zone to one that is not UTC for one test, so a time said locally is proven to be, wherever +// the test runs. +func inZone(t *testing.T) { + t.Helper() + was := time.Local + time.Local = time.FixedZone("CEST", 2*60*60) + t.Cleanup(func() { time.Local = was }) +} + +// novox/hq issue 497: a clear the operator's channel refused keeps its notice as told, and is sent again at the next +// step until it is taken. +func TestAClearThatFailsIsSentAgain(t *testing.T) { + eachStore(t, func(t *testing.T, s Store) { + w := imported(t, s) + n := proposed(t, w, "author@g14", false) + w.judge.reviewFail = true + for try := 1; try <= MostReviewTries; try++ { + w.reviewAll(t) + w.clock = w.clock.Add(time.Duration(try) * ReviewPause) + } + if len(w.toldKeys) != 1 { + t.Fatalf("after the last try: told %v", w.toldKeys) + } + prefix := fmt.Sprintf("knowledge.unreviewed.decision-%d.", n) + refused := 0 + w.k.Clear = func(key, resolved string) error { refused++; return errors.New("the channel is down") } + if _, err := w.k.Propose("author@g14", valid(n)); err != nil { + t.Fatal(err) + } + w.reviewAll(t) + if told, err := s.ToldOf(context.Background(), prefix); err != nil || len(told) != 1 || refused == 0 { + t.Fatalf("a refused clear: told %v (%v), refused %d", told, err, refused) + } + w.k.Clear = func(key, resolved string) error { w.cleared = append(w.cleared, [2]string{key, resolved}); return nil } + w.clock = w.clock.Add(time.Second) + w.reviewAll(t) + if told, err := s.ToldOf(context.Background(), prefix); err != nil || len(told) != 0 || len(w.cleared) != 1 || + w.cleared[0][0] != w.toldKeys[0] { + t.Fatalf("the next step: told %v (%v), cleared %v", told, err, w.cleared) + } + }) +}