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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"}},
|
||||
|
||||
@@ -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++
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"time-zone": "",
|
||||
"only-what-needs-me": []
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user