Feature 57 (config and provisioning): modules declare config and provisions, their files rendered from templates; synced with main (issues of feature 57, 874)

This commit is contained in:
2026-10-16 07:37:43 +00:00
parent 1a2c917abf
commit 9652b2d5ce
771 changed files with 52063 additions and 1774 deletions
+3 -1
View File
@@ -86,7 +86,9 @@ func (h *Holder) choices(r *Record, kind string) []Choice {
if rec := h.openAsk(r); rec != nil {
out = h.offers(rec, kind)
}
if r.Origin != "condition" || len(out) >= MostActions {
// A condition offers Details so the operator can read more; a tell does too (ADR 0380 §8: "Details is
// offered as for any message") — it is its own message, so Details just shows it again.
if (r.Origin != "condition" && r.Origin != "tell") || len(out) >= MostActions {
return out
}
now := h.Now()
@@ -365,10 +365,12 @@ func TestAnApproveFromTheLinkedAccountIsAWarrantForTheAskerAlone(t *testing.T) {
for _, m := range ch.edits {
edited = m
}
// The question's title is how it ended, read at a glance; then what the router knows of the asker (issue 372).
if len(edited.Actions) != 0 || edited.Title != "You chose Release at 12:00 on Telegram · Delivery of hq held for 36 hours" ||
edited.Body != "" || edited.Note != "Told to mesh-delivery, which acts on it itself." {
t.Errorf("%s still offers %q, or does not say how it ended in one line (issue 639): %q / %q / %q", ch.name, labelsOf(edited), edited.Title, edited.Body, edited.Note)
// The question stays whole (issue 714); below it, how it ended and what the router knows of the asker
// (issue 372).
if len(edited.Actions) != 0 || edited.Title != "Delivery of hq held for 36 hours" ||
edited.Body != heldAsk(c, "d-1").Explanation ||
edited.Note != "You chose Release at 12:00 on Telegram.\nTold to mesh-delivery, which acts on it itself." {
t.Errorf("%s still offers %q, or does not keep the question and say how it ended (issue 714): %q / %q / %q", ch.name, labelsOf(edited), edited.Title, edited.Body, edited.Note)
}
}
// The tap is confirmed where it was made, in reply to it, once the warrant stood — and before the question
@@ -624,8 +626,8 @@ func TestAnAskExpiresAndNeverDefaults(t *testing.T) {
for _, m := range tg.edits {
edited = m
}
if len(edited.Actions) != 0 || !strings.HasPrefix(edited.Title, "No longer answerable: nobody answered in time · Delivery of hq") ||
edited.Body != "" || edited.Note != "The delivery stays held." {
if len(edited.Actions) != 0 || !strings.HasPrefix(edited.Title, "Delivery of hq") ||
edited.Body == "" || edited.Note != "No longer answerable: nobody answered in time.\nThe delivery stays held." {
t.Errorf("the copy still offers answers, or does not say how it ended: %+v", edited)
}
}
@@ -51,7 +51,7 @@ func TestAnApprovalIsConfirmedWhereItWasGivenAndThenTheQuestionSaysHowItEnded(t
}
want := []string{
"send You chose Release: recorded, and mesh-delivery was told.",
"edit You chose Release at 12:00 on Telegram · Delivery of hq held for 36 hours",
"edit Delivery of hq held for 36 hours",
}
if strings.Join(log, "\n") != strings.Join(want, "\n") {
t.Fatalf("the operator saw, in order:\n%s\nwant:\n%s", strings.Join(log, "\n"), strings.Join(want, "\n"))
@@ -122,7 +122,7 @@ func TestTheConfirmationNeverClaimsWhatDidNotHappen(t *testing.T) {
for _, m := range tg.edits {
edited = m
}
if !strings.HasPrefix(edited.Note, "Told to mesh-delivery as soon as the bus takes it") {
if !strings.Contains(edited.Note, "\nTold to mesh-delivery as soon as the bus takes it") {
t.Errorf("the question claims its asker was told: %q", edited.Note)
}
})
@@ -9,8 +9,8 @@ import (
)
// novox/hq issue 639: an ask's message reads at a glance on the phone. Its body is the asker's summary and points
// alone; who asked and when, what each answer does, the expiry and the asker's Details are its note; once answered
// it is one line with the headline.
// alone; who asked and when, what each answer does, the expiry and the asker's Details are its note. Once answered
// it keeps the question whole, so the operator can read back what they answered (issue 714).
// decisionAsk is the ask for ADR 0343 as the knowledge base composes it now (its glance form).
func decisionAsk(c *clock) asks.Ask {
@@ -59,7 +59,8 @@ func TestAnAsksMessageIsItsSummaryWithTheRestInItsNote(t *testing.T) {
if n := len(strings.Split(m.Text(), "\n")); n > 9 || len(m.Actions) != 2 {
t.Errorf("the message is %d lines offering %s:\n%s", n, labelsOf(m), m.Text())
}
// Answered, it is one line with the headline; the body is not said again.
// Answered, it keeps its headline, summary, points and Details; below them, how it ended and who acts on it;
// its buttons are gone (issue 714: the one line issue 639 made left nothing to read back).
if out, err := h.Chosen("telegram", choiceOf(ticketFor(m, "Approve"), "42", c.now())); err != nil || out != "chosen" {
t.Fatalf("Approve: %q %v", out, err)
}
@@ -67,8 +68,9 @@ func TestAnAsksMessageIsItsSummaryWithTheRestInItsNote(t *testing.T) {
for _, e := range tg.edits {
edited = e
}
if edited.Title != "You chose Approve at "+asked+" on Telegram · "+a.Headline || edited.Body != "" ||
strings.Contains(edited.Note, "What an agent learns") || len(edited.Actions) != 0 {
if edited.Title != a.Headline || edited.Body != a.Explanation+"\n"+want[2]+"\n"+want[3] ||
edited.Note != "You chose Approve at "+asked+" on Telegram.\nTold to mesh-knowledge, which acts on it itself." ||
len(edited.Actions) != 0 {
t.Errorf("the answered copy: %q\n%q\n%q", edited.Title, edited.Body, edited.Note)
}
}
+71 -34
View File
@@ -321,30 +321,33 @@ type Holder struct {
work sync.Mutex // one event, call or tick at a time
mu sync.Mutex // what the status reads
open map[string]*Record
recent []Sent
refusals []RefusalNote
outbox map[string][]item
lastSend map[string]time.Time
lastWarn map[string]time.Time
lastTry map[string]time.Time
capSaid map[string]time.Time
chanErr map[string]string
chanErrAt map[string]time.Time
chanOK map[string]time.Time
unreadable int
lastBad string
lastBadAt time.Time
saidOnce map[string]time.Time
storeErr string
heard map[string]int
lastHeard time.Time
started time.Time
syncedAt time.Time
syncErr string
wantSync bool
history int
stale int
open map[string]*Record
recent []Sent
refusals []RefusalNote
outbox map[string][]item
lastSend map[string]time.Time
lastWarn map[string]time.Time
lastTry map[string]time.Time
capSaid map[string]time.Time
tellCapSaid map[string]time.Time
chanErr map[string]string
chanErrAt map[string]time.Time
chanOK map[string]time.Time
unreadable int
lastBad string
lastBadAt time.Time
saidOnce map[string]time.Time
storeErr string
heard map[string]int
lastHeard time.Time
started time.Time
syncedAt time.Time
syncErr string
wantSync bool
history int
stale int
// tellSeq makes every tell's key its own, even when two calls land in the same instant.
tellSeq int
// answered is what came of each answer event, by its id: a redelivered one is acted on once.
answered map[string]string
// asks are the asks this router holds, by `<asker>.<id>`, and tickets each ask's tickets to it.
@@ -374,6 +377,7 @@ func (h *Holder) init() {
h.lastWarn = map[string]time.Time{}
h.lastTry = map[string]time.Time{}
h.capSaid = map[string]time.Time{}
h.tellCapSaid = map[string]time.Time{}
h.chanErr = map[string]string{}
h.chanErrAt = map[string]time.Time{}
h.chanOK = map[string]time.Time{}
@@ -856,7 +860,7 @@ func (h *Holder) sayOn(r *Record, what, kind string) Message {
}
}
if r.Answered != "" {
m = h.answeredWords(r, m)
m = h.answeredWords(r, m, kind)
}
if kind == "" {
return m
@@ -910,18 +914,24 @@ func (h *Holder) sayOn(r *Record, what, kind string) Message {
return m
}
// answeredWords is a message that says what came of its question. An ask's own message is the question
// itself, so it becomes how it ended and what it was about — "You chose Approve at 13:30 on Telegram ·
// <headline>" — one line, read at a glance on the phone, where the edit rings nothing (issue 372); the body
// is not said again (issue 639), and what the router knows of the asker after it is the note. A condition's
// message keeps its own title, and says it first.
func (h *Holder) answeredWords(r *Record, m Message) Message {
// answeredWords is a message that says what came of its question. An ask's own message keeps the question
// whole — its headline as the title, the asker's summary, points and link as the body, its Details after them —
// so the operator can read back what they answered (issue 714, which undid issue 639's one line). How it ended
// ("You chose Approve at 13:30 on Telegram.") and what the router knows of the asker after it are the note,
// below the question, and its answers are gone (issue 372). A channel that must shorten it cuts the body from
// its end, the Details first; never the headline or the note. A condition's message keeps its own title, and
// says how it ended first.
func (h *Holder) answeredWords(r *Record, m Message, kind string) Message {
if r.Origin == "ask" {
first, rest, _ := strings.Cut(r.Answered, "\n")
headline, _ := wordsOf(r)
out := Message{Title: strings.TrimSuffix(first, ".") + " · " + headline, Note: strings.TrimSpace(rest),
body := []string{}
if b := strings.TrimSpace(m.Body); b != "" {
body = append(body, b)
}
body = append(body, h.answeredDetails(r, kind)...)
out := Message{Title: headline, Body: strings.Join(body, "\n"), Note: strings.TrimSpace(r.Answered),
Urgent: m.Urgent, Quiet: m.Quiet}
if _, ok := Check(out.Text()); ok {
if _, ok := Check(out.Title + "\n" + out.Note); ok {
return out
}
// Its headline refused (it is the asker's): the outcome is still said, before what may leave.
@@ -930,6 +940,33 @@ func (h *Holder) answeredWords(r *Record, m Message) Message {
return m
}
// answeredDetails are an answered ask's Details, line by line, as its open message noted them (issue 714): whole
// on a kind that proves who answers, a line that may not leave withheld and said so.
func (h *Holder) answeredDetails(r *Record, kind string) []string {
if r.Ask == "" {
return nil
}
e := h.askOf(r.Ask)
if e == nil {
return nil
}
details, passes := e.rec.Ask.Details, Check
if wd := h.wholeDetailsOf(r, kind); wd != "" {
details, passes = wd, CheckSecret
}
var lines []string
for _, line := range strings.Split(strings.TrimSpace(details), "\n") {
if line = strings.TrimSpace(line); line == "" {
continue
}
if refusal, ok := passes(line); !ok {
line = withheldLine(line, refusal, passes)
}
lines = append(lines, line)
}
return lines
}
// wholeWords is a record's message with the ask's whole words in place of its explanation, when they carry no
// secret's shape once composed; else false, and the message under the whole content rule stands.
func (h *Holder) wholeWords(r *Record, what, whole string) (Message, bool) {
@@ -524,6 +524,161 @@ func TestNotifyIsKeptApartFromConditions(t *testing.T) {
}
}
func TestATellReachesEveryChannelOnceAndOffersOnlyDetails(t *testing.T) {
h, tg, dt, c, _ := newHolder(t)
c.pass(14 * time.Hour) // well past midnight: it is not held for being "night" — there is no such hold
out, err := h.Tell("new mail arrived for ace", []string{"One message from the inbox you watch."}, "mail")
if err != nil {
t.Fatal(err)
}
if len(tg.sends) != 1 || len(dt.sends) != 1 {
t.Fatalf("telegram %d, desktop %d sends", len(tg.sends), len(dt.sends))
}
for _, m := range []Message{tg.sends[0], dt.sends[0]} {
if len(m.Actions) != 1 || m.Actions[0].Label != DetailsLabel {
t.Fatalf("actions %+v", m.Actions)
}
}
if sent, _ := out["sent_on"].([]string); len(sent) != 2 {
t.Fatalf("sent_on %v", out)
}
if len(h.Open()) != 0 {
t.Fatalf("a tell left an open record: %+v", h.Open())
}
c.pass(RemindUrgent + time.Minute)
h.Tick()
if len(tg.sends) != 1 || len(dt.sends) != 1 {
t.Fatalf("a tell was reminded: telegram %d, desktop %d", len(tg.sends), len(dt.sends))
}
}
func TestATellIsDroppedPastItsOwnHourlyCap(t *testing.T) {
h, tg, _, _, _ := newHolder(t)
h.Desk = nil
for i := 0; i < TellCapPerHour; i++ {
if _, err := h.Tell("mail", []string{"line"}, ""); err != nil {
t.Fatal(err)
}
}
if len(tg.sends) != TellCapPerHour {
t.Fatalf("sent %d of %d", len(tg.sends), TellCapPerHour)
}
out, err := h.Tell("mail", []string{"line"}, "")
if err != nil {
t.Fatal(err)
}
if len(tg.sends) != TellCapPerHour {
t.Fatalf("the 11th tell was sent: %d", len(tg.sends))
}
dropped, _ := out["dropped"].([]string)
if len(dropped) != 1 || dropped[0] != "telegram" {
t.Fatalf("dropped %v", out)
}
}
func TestATellDoesNotCountTowardTheConditionsCap(t *testing.T) {
h, tg, _, c, _ := newHolder(t)
h.Desk = nil
// Bring conditions close to, but under, their own cap: CapPerHour-5.
for i := 0; i < CapPerHour-5; i++ {
h.Condition(EventRaised, cond(fmt.Sprintf("machine.m%d.silent", i), Urgent, "a machine is silent"))
c.pass(BurstWindow + time.Second)
h.Flush()
}
if len(tg.sends) != CapPerHour-5 {
t.Fatalf("sent %d conditions, want %d", len(tg.sends), CapPerHour-5)
}
// A full hour of tells: these must not be counted against the conditions' cap (ADR 0380 §8).
for i := 0; i < TellCapPerHour; i++ {
if _, err := h.Tell("mail", []string{"line"}, ""); err != nil {
t.Fatal(err)
}
}
h.Condition(EventRaised, cond("machine.mlast.silent", Urgent, "a machine is silent"))
c.pass(BurstWindow + time.Second)
h.Flush()
want := CapPerHour - 5 + TellCapPerHour + 1
if len(tg.sends) != want {
t.Fatalf("sent %d, want %d (the condition after the tells still sent)", len(tg.sends), want)
}
if last := tg.sends[len(tg.sends)-1]; strings.HasPrefix(last.Title, "Mesh messages paused for this hour") {
t.Fatalf("the condition was held behind the tells' sends: %q", last.Title)
}
}
func TestATellCapDropIsLoggedOnceAnHour(t *testing.T) {
h, tg, _, _, _ := newHolder(t)
h.Desk = nil
var logs []string
h.Logf = func(format string, args ...any) { logs = append(logs, fmt.Sprintf(format, args...)) }
for i := 0; i < TellCapPerHour; i++ {
if _, err := h.Tell("mail", []string{"line"}, ""); err != nil {
t.Fatal(err)
}
}
for i := 0; i < 3; i++ {
if _, err := h.Tell("mail", []string{"line"}, ""); err != nil {
t.Fatal(err)
}
}
if len(tg.sends) != TellCapPerHour {
t.Fatalf("sent %d of %d", len(tg.sends), TellCapPerHour)
}
dropLogs := 0
for _, l := range logs {
if strings.Contains(l, "dropped") {
dropLogs++
}
}
if dropLogs != 1 {
t.Fatalf("logged the drop %d times, want 1", dropLogs)
}
}
// TestAFloodOfDroppedTellsDoesNotEvictTheConditionsCapHistory is review round 2 of issue 800's messenger
// finding (issue 797's area): a dropped tell used to be kept in the same bounded send history the conditions'
// cap counts from (KeptSends); a flood of them could evict real, still-recent sends from it, undercounting
// sentLastHour and letting the conditions' cap stop holding. Here the cap is filled exactly, then 300 tells
// are dropped — far more than KeptSends — and the next condition must still be held, exactly as it would be
// without the flood.
func TestAFloodOfDroppedTellsDoesNotEvictTheConditionsCapHistory(t *testing.T) {
h, tg, _, c, _ := newHolder(t)
h.Desk = nil
// Fill the conditions' cap exactly: CapPerHour sent, none held yet.
for i := 0; i < CapPerHour; i++ {
h.Condition(EventRaised, cond(fmt.Sprintf("machine.m%d.silent", i), Urgent, "a machine is silent"))
c.pass(BurstWindow + time.Second)
h.Flush()
}
if len(tg.sends) != CapPerHour {
t.Fatalf("sent %d conditions, want the cap of %d", len(tg.sends), CapPerHour)
}
// Use up the tell cap, then flood 300 more: all past TellCapPerHour are dropped, far more than KeptSends.
for i := 0; i < TellCapPerHour; i++ {
if _, err := h.Tell("mail", []string{"line"}, ""); err != nil {
t.Fatal(err)
}
}
for i := 0; i < 300; i++ {
if _, err := h.Tell("mail", []string{"line"}, ""); err != nil {
t.Fatal(err)
}
}
// One more condition, past the cap: it must still be held, as it would be without the flood of drops.
h.Condition(EventRaised, cond("machine.mlast.silent", Urgent, "a machine is silent"))
c.pass(BurstWindow + time.Second)
h.Flush()
want := CapPerHour + TellCapPerHour + 1 // the cap's own conditions, the tells that did send, and the cap message
if len(tg.sends) != want {
t.Fatalf("sent %d, want %d (the cap still held after a flood of dropped tells)", len(tg.sends), want)
}
if last := tg.sends[len(tg.sends)-1]; !strings.HasPrefix(last.Title, "Mesh messages paused for this hour") {
t.Fatalf("the cap was not said after the flood of dropped tells: %q", last.Title)
}
}
func TestATestMessageRespectsTheRule(t *testing.T) {
h, tg, dt, _, _ := newHolder(t)
if out := h.Test("telegram", "see https://example.org"); out["refused"] == "" || len(tg.sends) != 0 {
+21
View File
@@ -645,6 +645,27 @@ func tools(h *Holder, l *listening) []stdio.Tool {
return h.Notify(strArg(a, "key"), strArg(a, "severity"), strArg(a, "summary"), strArg(a, "subject"), clear,
strArg(a, "headline"), strArg(a, "explanation"), strArg(a, "resolved"))
}},
{Name: "operator-channel.tell",
Description: "Tell the operator a plain notice that needs no answer and opens no condition (ADR 0380): a " +
"headline and one or two lines, said at once on every channel, held to the content rule as notify's " +
"words are, and offering Details like any message. Never escalated, never kept open, never reminded; " +
"its own cap of 10 a channel an hour, apart from the conditions' and notify's.",
Input: map[string]any{
"headline": str("a few plain words the operator reads as the title, e.g. new mail arrived for ace"),
"lines": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "one or two plain lines"},
"subject": str("what it is about: a machine's role, a module, a plan"),
},
Run: func(a map[string]any) (any, error) {
var lines []string
if raw, ok := a["lines"].([]any); ok {
for _, l := range raw {
if s, ok := l.(string); ok {
lines = append(lines, s)
}
}
}
return h.Tell(strArg(a, "headline"), lines, strArg(a, "subject"))
}},
{Name: "messenger_status",
Description: "Whether the operator can be reached, and why not: each channel kind the mesh holds — can it " +
"send, what it said of itself, its last error, how many it sent in the last hour against the cap, how " +
@@ -62,7 +62,7 @@ func readManifest(t *testing.T) (manifest, string) {
func TestItDeclaresTheSeatsOfAnAskAndHoldsTheOperatorChannel(t *testing.T) {
m, _ := readManifest(t)
verbs := []string{"open", "history", "notify", "asks"}
verbs := []string{"open", "history", "notify", "tell", "asks"}
want := []seat{
{Name: asks.Seat, Scope: "mesh", Serves: verbs, Accepts: []string{"ask", "cancel"}, Emits: []string{"decided"},
ByCaller: []string{"ask", "cancel", "decided"}, Records: []string{"asks"}},
+27 -2
View File
@@ -609,7 +609,9 @@ func (h *Holder) sendNow(ch Channel, key, what string, m Message) bool {
return true
}
// allow says whether a channel is under its cap: messages sent in the last hour, as recorded.
// allow says whether a channel is under its cap: messages sent in the last hour, as recorded. A
// tell is not counted here: it has its own cap (TellCapPerHour) so a burst of tells cannot crowd
// out a condition or an ask (ADR 0380 §8).
func (h *Holder) allow(channel string) bool {
return h.sentLastHour(channel) < CapPerHour
}
@@ -620,7 +622,30 @@ func (h *Holder) sentLastHour(channel string) int {
since := h.Now().Add(-time.Hour)
n := 0
for _, s := range h.recent {
if s.Channel == channel && s.Outcome == "sent" && s.At.After(since) {
if s.Channel == channel && s.What != "tell" && s.Outcome == "sent" && s.At.After(since) {
n++
}
}
return n
}
// TellCapPerHour: at most this many tells reach one channel in an hour — the tell verb's own cap
// (ADR 0380 §8), never shared with CapPerHour, the conditions' and notify's, so a burst of plain
// notices cannot crowd out a condition or an ask.
const TellCapPerHour = 10
// allowTell says whether a channel is under tell's own cap.
func (h *Holder) allowTell(channel string) bool {
return h.tellSentLastHour(channel) < TellCapPerHour
}
func (h *Holder) tellSentLastHour(channel string) int {
h.mu.Lock()
defer h.mu.Unlock()
since := h.Now().Add(-time.Hour)
n := 0
for _, s := range h.recent {
if s.Channel == channel && s.What == "tell" && s.Outcome == "sent" && s.At.After(since) {
n++
}
}
+1 -1
View File
@@ -91,7 +91,7 @@ func (h *Holder) Preview(key, what string) (map[string]any, error) {
asking := h.openAsk(&r) != nil // askOf takes the same lock, so this is asked outside it
m, line, refusal, ownWords := h.allowedWords(&r, what, now)
if r.Answered != "" {
m = h.answeredWords(&r, m)
m = h.answeredWords(&r, m, "")
}
out := map[string]any{
"key": key,
@@ -50,7 +50,7 @@ func TestAReplacedAsksMessageEndsWithIt(t *testing.T) {
m, edited = r.m, true
}
}
if !edited || len(m.Actions) != 0 || !strings.Contains(m.Title, "Replaced by a newer question") {
if !edited || len(m.Actions) != 0 || !strings.HasPrefix(m.Note, "Replaced by a newer question") {
t.Errorf("%s: the replaced ask's message still stands: edited %v, %q %v", ch.name, edited, m.Text(), labelsOf(m))
}
}
@@ -86,7 +86,7 @@ func TestAnAsksMessageLeftOpenAfterItsAskEndedIsEndedOnTheNextTick(t *testing.T)
if got := openAskRecords(h); len(got) != 0 {
t.Fatalf("the message of an ended ask is still open: %v", got)
}
if m, ok := tg.edits["1"]; !ok || len(m.Actions) != 0 || !strings.Contains(m.Title, "Replaced by a newer question") {
if m, ok := tg.edits["1"]; !ok || len(m.Actions) != 0 || !strings.HasPrefix(m.Note, "Replaced by a newer question") {
t.Errorf("the leftover's copy was not ended: %v %q %v", ok, m.Text(), labelsOf(m))
}
}
+89
View File
@@ -1,7 +1,9 @@
package main
import (
"fmt"
"sort"
"strings"
"time"
)
@@ -309,3 +311,90 @@ func (h *Holder) Notify(key, severity, summary, subject string, clear bool, plai
held := h.queued(key)
return map[string]any{"key": key, "sent_on": sent, "held": held, "refused": refused, "unsent": pending}, nil
}
// Tell sends a plain notice that needs no answer and opens no condition (ADR 0380 §8): one message with a
// headline and one or two lines, said at once on every channel the router sends on — bypassing the severity
// routing, the burst window and the walk hold that a condition's message goes through, and never reminded,
// escalated or kept open. Composed and held to the content rule exactly as notify's words are (allowedWords'
// reduction, withheld parts as today). It offers Details, like any message, and is kept in the history so
// `history` shows it — but its record is already marked cleared, so `open` never shows it and it is forgotten
// after ReopenWindow, as any cleared record is. Its cap, TellCapPerHour a channel an hour, is its own and
// never shared with CapPerHour: past it, a tell is dropped, said once an hour in the log and named in this
// call's own "dropped" answer, but never kept in the bounded send history the conditions' cap counts from
// (KeptSends, outbox.go's sentLastHour) — a flood of drops must never evict a real, still-recent send from it
// and let that cap stop holding (review round 2 of issue 800, issue 797's area).
func (h *Holder) Tell(headline string, lines []string, subject string) (map[string]any, error) {
headline = strings.TrimSpace(headline)
if headline == "" {
return nil, errorf("headline is required")
}
if len(lines) == 0 || len(lines) > 2 {
return nil, errorf("lines takes one or two lines")
}
clean := make([]string, 0, len(lines))
for _, l := range lines {
l = strings.TrimSpace(l)
if l == "" {
return nil, errorf("a line is empty")
}
clean = append(clean, l)
}
h.work.Lock()
defer h.work.Unlock()
h.mu.Lock()
h.init()
h.mu.Unlock()
now := h.Now()
h.mu.Lock()
h.tellSeq++
seq := h.tellSeq
h.mu.Unlock()
key := fmt.Sprintf("tell.%d.%d", now.UnixNano(), seq)
rec := &Record{Key: key, Kind: "tell", Subject: subject, Origin: "tell", Headline: headline,
Explanation: strings.Join(clean, "\n"), Summary: headline, Raised: now, Seen: now, Count: 1,
Sent: map[string]string{}, Cleared: now}
h.mu.Lock()
h.open[key] = rec
h.mu.Unlock()
var sentOn, dropped []string
for _, ch := range h.channels() {
name := ch.Name()
if ch.Ready() != nil {
continue
}
if !h.allowTell(name) {
dropped = append(dropped, name)
h.mu.Lock()
said := h.tellCapSaid[name]
sayIt := said.IsZero() || now.Sub(said) >= time.Hour
if sayIt {
h.tellCapSaid[name] = now
}
h.mu.Unlock()
if sayIt {
h.Logf("[messenger] a tell was dropped on %s: it is at its own cap of %d tells an hour", name, TellCapPerHour)
}
// Not h.record'd: that history is bounded (KeptSends) and is what the conditions' cap counts from
// (sentLastHour, outbox.go); a flood of drops must never evict a real send from it and let the
// conditions' cap stop holding (review round 2 of issue 800, issue 797's area). This call's own
// "dropped" answer, and the once-an-hour log line above, already say it happened.
continue
}
m := h.sayOn(rec, "tell", name)
id, err := ch.Send(m)
h.noteChannel(name, err)
if err != nil {
h.record(Sent{At: now, Channel: name, Key: key, What: "tell", Outcome: "failed: " + err.Error()})
continue
}
h.mu.Lock()
rec.Sent[name] = id
h.mu.Unlock()
sentOn = append(sentOn, name)
h.record(Sent{At: now, Channel: name, Key: key, What: "tell", Outcome: "sent"})
}
h.persist(rec)
sort.Strings(sentOn)
sort.Strings(dropped)
return map[string]any{"key": key, "sent_on": sentOn, "dropped": dropped, "refused": rec.Refused}, nil
}
@@ -0,0 +1,64 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"testing"
)
// The module's directory, where its manifest and its files/ are.
const moduleDir = "../.."
// templateText is the text a file resource places on the machine: its template's, in the module's files/
// (novox/hq ADR 0349 §2). A resource that names no template, or a template that is not there, fails the test.
func templateText(t *testing.T, r map[string]any) string {
t.Helper()
name, _ := r["template"].(string)
if name == "" {
t.Fatalf("resource %v names no template", r["id"])
}
raw, err := os.ReadFile(filepath.Join(moduleDir, filepath.FromSlash(name)))
if err != nil {
t.Fatalf("resource %v names %s: %v", r["id"], name, err)
}
return string(raw)
}
// No file resource carries its text inline: each names its template in files/, and the build packs files/
// into the one archive the controller reads the templates back from (novox/hq ADR 0349 §2).
func TestEveryFileTakesItsTextFromATemplate(t *testing.T) {
raw, err := os.ReadFile(filepath.Join(moduleDir, "module.json"))
if err != nil {
t.Fatal(err)
}
var m struct {
Resources []map[string]any `json:"resources"`
Build struct {
Artifacts []map[string]any `json:"artifacts"`
} `json:"build"`
}
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
for _, r := range m.Resources {
if _, inline := r["content"]; inline {
t.Errorf("resource %v keeps its text inline: keep it in files/ and name it as the template", r["id"])
}
if r["type"] == "file" {
templateText(t, r)
}
}
packs := 0
for _, a := range m.Build.Artifacts {
if a["kind"] == "archive" && a["from"] == "files" {
packs++
if a["name"] != "files" {
t.Errorf("the templates archive is named %v, not files", a["name"])
}
}
}
if packs != 1 {
t.Errorf("the build packs files/ into %d archives, not one", packs)
}
}
@@ -61,8 +61,7 @@ func TestAnAsksWholeWordsAreShownWhereTheSenderIsProvenAndMaskedElsewhere(t *tes
if got := w.of("mesh-controller"); len(got) != 0 || store.asks["mesh-controller.s-1"].State != StateOpen {
t.Errorf("the ask was answered: %+v / %s", got, store.asks["mesh-controller.s-1"].State)
}
// Approved, the message is edited to one line that says so, with the headline; the body is not said again
// (issue 639).
// Approved, the message keeps its headline and words, and says below them how it ended (issue 714).
if out, err := h.Chosen("telegram", choiceOf(ticketFor(phone, "Release"), "42", c.now())); err != nil || out != "chosen" {
t.Fatalf("Release: %q %v", out, err)
}
@@ -70,8 +69,8 @@ func TestAnAsksWholeWordsAreShownWhereTheSenderIsProvenAndMaskedElsewhere(t *tes
if !ok {
t.Fatalf("the phone's message was not edited: %+v", tg.edits)
}
if !strings.HasPrefix(edited.Title, "You chose Release at ") || !strings.HasSuffix(edited.Title, " · Set mounts on shanks?") ||
edited.Body != "" || len(edited.Actions) != 0 {
if edited.Title != "Set mounts on shanks?" || edited.Body == "" ||
!strings.HasPrefix(edited.Note, "You chose Release at ") || len(edited.Actions) != 0 {
t.Errorf("the edited message: %q\n%s\nactions %+v", edited.Title, edited.Body, edited.Actions)
}
}
+4
View File
@@ -63,6 +63,10 @@ func compose(r *Record, what string, now time.Time, loc *time.Location, withhold
if urgent {
title = "Urgent: " + title
}
case "tell":
// A plain notice (ADR 0380 §1, §8): the headline and its one or two lines, nothing else — no
// "since", no severity, no reminder: it waits for no answer and is never said again.
return Message{Title: headline, Body: explanation}
default:
title = headline
if urgent {