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
+6 -4
View File
@@ -49,8 +49,9 @@ channel's client and no channel's secret.
warrant published to the asker alone. A warrant the bus did not take is published again every minute.
- **What the operator sees when they answer** (issue 372): first, in reply to the answer where it was given,
that it was recorded and whether its asker was told ("You chose Approve: recorded, and claude-code was
told.") — said only once the ask's end is recorded — or, refused, why; then the ask's message edited to how
it ended, as its title ("You chose Approve at 13:30 on Telegram · <headline>"), with no answers left on it.
told.") — said only once the ask's end is recorded — or, refused, why; then the ask's message edited with no
answers left on it: its question whole (headline, summary, points, link, Details), and below it how it ended
("You chose Approve at 13:30 on Telegram.") and who acts on it (issue 714).
What the asker then did is not said: the asks protocol carries no result back to the router, so the
message says only that the asker was told and acts on it itself.
- **An ask never defaults**: at its expiry it ends `expired` and its asker does what it said it would.
@@ -138,8 +139,9 @@ and said in the log). Everything else is the message's note (`asks.Message.Note`
smaller: who asked and when, with what happens if nobody answers; what each answer does; where else it is answered;
and the asker's Details (who proposed it, a fingerprint, how to read it whole) — on a kind that proves who answers
and was given the ask's answers, its Details whole (`asks.Ask.WholeDetails`): every value an approval sets, held to
the secret rule alone, as the whole words are. Answered, the message is one line —
"You chose Approve at 16:06 on Telegram · <headline>" — and the router's word on the asker is its note.
the secret rule alone, as the whole words are. Answered, the message keeps its headline, body and Details, so the
operator can read back what they answered (issue 714); its note becomes "You chose Approve at 16:06 on Telegram."
with the router's word on the asker.
**An ask's values are shown whole where the sender is proven** (hq issue 383). An ask may carry its explanation
twice: as it is held to the whole content rule, and whole (`asks.Ask.Whole`), its paths and addresses as they
+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 {
+4
View File
@@ -0,0 +1,4 @@
{
"time-zone": "",
"only-what-needs-me": []
}
+29 -1
View File
@@ -11,6 +11,7 @@
"open",
"history",
"notify",
"tell",
"asks"
],
"accepts": [
@@ -62,6 +63,7 @@
"open",
"history",
"notify",
"tell",
"asks"
]
}
@@ -110,6 +112,26 @@
"messenger_identities",
"messenger_unlink"
],
"config": {
"time-zone": {
"type": "string",
"description": "The time zone every time a message says is said in, an IANA name such as Europe/Brussels; empty is this machine's own zone.",
"trusted": true,
"default": ""
},
"desktop-machines": {
"type": "list",
"description": "The machines whose desktop showed the operator's messages, by node name. Nothing in this module reads it since the desk channel took the desktop over; it is taken so the file stays as a layer that still sets it makes it, until that layer drops it.",
"trusted": true,
"default": []
},
"only-what-needs-me": {
"type": "list",
"description": "The kinds of channel set to say only what needs the operator: a walk that delivered with nothing held and nothing open is not told there (ADR 0308 §4); empty is none.",
"trusted": true,
"default": []
}
},
"resources": [
{
"id": "account",
@@ -130,8 +152,9 @@
"type": "file",
"path": "${dir:state}/settings.json",
"mode": "0600",
"takes-config": ["time-zone", "desktop-machines", "only-what-needs-me"],
"merge": "json",
"content": "{\n \"time-zone\": \"\",\n \"only-what-needs-me\": []\n}\n",
"template": "files/settings.json",
"owner": "messenger"
}
],
@@ -151,6 +174,11 @@
"MESH_MESSENGER_SETTINGS": "${dir:state}/settings.json",
"MESH_MESSENGER_STATE": "${dir:state}"
}
},
{
"name": "files",
"kind": "archive",
"from": "files"
}
]
}