mesh/merge-gate pass: builds messenger → novox; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery-group group feat/plain-notifications ready: every member ready, and composed together they pass
mesh/delivery superseded: a newer head of the same pull request
The desktop popups were walls of plan ids, commits, keys and verb syntax in UTC. The messenger now shows the condition's headline, explanation and resolved line, since when in the operator's time zone, and a clearance in one line (hq ADR 0253).
302 lines
10 KiB
Go
302 lines
10 KiB
Go
package main
|
|
|
|
import (
|
|
"sort"
|
|
"time"
|
|
)
|
|
|
|
// ChannelStatus is one channel as the status says it.
|
|
type ChannelStatus struct {
|
|
Name string `json:"name"`
|
|
CanSend bool `json:"can_send"`
|
|
NotReady string `json:"not_ready,omitempty"`
|
|
LastError string `json:"last_error,omitempty"`
|
|
LastSent string `json:"last_sent,omitempty"`
|
|
SentLastHour int `json:"sent_last_hour"`
|
|
Cap int `json:"cap_per_hour"`
|
|
Held int `json:"held"` // waiting for their window, the digest or the cap
|
|
}
|
|
|
|
// Status is the holder's whole account of itself.
|
|
type Status struct {
|
|
Verdict string `json:"verdict"`
|
|
Channels []ChannelStatus `json:"channels"`
|
|
Open int `json:"open"`
|
|
Silenced int `json:"silenced"`
|
|
Unsent []string `json:"unsent,omitempty"`
|
|
Refused int `json:"refused_since_start"`
|
|
Unreadable int `json:"unreadable_events_since_start"`
|
|
LastBad string `json:"last_unreadable,omitempty"`
|
|
Heard map[string]int `json:"events_heard_since_start"`
|
|
LastHeard string `json:"last_event_heard,omitempty"`
|
|
Listening string `json:"listening"`
|
|
StateRead string `json:"state_read,omitempty"` // when the open conditions were last read from the controller
|
|
StateUnread string `json:"state_unreadable,omitempty"` // why they could not be, lately
|
|
History int `json:"events_already_in_state"` // older than the reading: not acted on
|
|
Stale int `json:"events_taken_as_state"` // older than the freshness bound: recorded, never said
|
|
Grace bool `json:"in_start_grace,omitempty"`
|
|
StateProblem string `json:"state_problem,omitempty"`
|
|
TimeZone string `json:"time_zone"` // the zone every time in a message is said in
|
|
Rules []string `json:"rules"`
|
|
}
|
|
|
|
// Status answers at once from what the holder keeps.
|
|
func (h *Holder) Status(listening string) Status {
|
|
now := h.Now()
|
|
var st Status
|
|
for _, ch := range h.channels() {
|
|
cs := ChannelStatus{Name: ch.Name(), Cap: CapPerHour, SentLastHour: h.sentLastHour(ch.Name())}
|
|
if err := ch.Ready(); err != nil {
|
|
cs.NotReady = err.Error()
|
|
} else {
|
|
cs.CanSend = true
|
|
}
|
|
h.mu.Lock()
|
|
cs.LastError = h.chanErr[ch.Name()]
|
|
if t, ok := h.chanOK[ch.Name()]; ok {
|
|
cs.LastSent = t.UTC().Format(time.RFC3339)
|
|
}
|
|
cs.Held = len(h.outbox[ch.Name()])
|
|
h.mu.Unlock()
|
|
if cs.LastError != "" {
|
|
cs.CanSend = false
|
|
}
|
|
st.Channels = append(st.Channels, cs)
|
|
}
|
|
h.mu.Lock()
|
|
for _, r := range h.open {
|
|
if !r.Cleared.IsZero() {
|
|
continue
|
|
}
|
|
st.Open++
|
|
if r.silenced(now) {
|
|
st.Silenced++
|
|
}
|
|
if r.Pending != "" {
|
|
st.Unsent = append(st.Unsent, r.Key+" ("+r.Pending+")")
|
|
}
|
|
}
|
|
st.Refused = len(h.refusals)
|
|
st.Unreadable = h.unreadable
|
|
st.LastBad = h.lastBad
|
|
st.Heard = map[string]int{}
|
|
for k, v := range h.heard {
|
|
st.Heard[k] = v
|
|
}
|
|
if !h.lastHeard.IsZero() {
|
|
st.LastHeard = h.lastHeard.UTC().Format(time.RFC3339)
|
|
}
|
|
st.StateProblem = h.storeErr
|
|
if !h.syncedAt.IsZero() {
|
|
st.StateRead = h.syncedAt.UTC().Format(time.RFC3339)
|
|
}
|
|
st.StateUnread = h.syncErr
|
|
st.History, st.Stale = h.history, h.stale
|
|
st.Grace = h.inGrace(now)
|
|
h.mu.Unlock()
|
|
sort.Strings(st.Unsent)
|
|
st.Listening = listening
|
|
st.TimeZone = h.zone().String()
|
|
st.Rules = []string{
|
|
"a message reads in one glance: the condition's headline as its title, its explanation, and since when in the operator's time zone; no key, id, commit or command — those stay in the controller's conditions and in this holder's history (novox/hq ADR 0253)",
|
|
"a clearance is one line: the condition's resolved line and how long it was open",
|
|
"history is never news: what is open is read from the controller on start, every 10 min and when an old event arrives; an event older than that reading is not acted on, one older than 10 min by its own time is state, never a message",
|
|
"a clearing of something never said says nothing; an urgent condition open and never said is said, all of them in one message",
|
|
"nothing is sent in the first minute after start but that one message",
|
|
"urgent: Telegram and the desktop; warning: the desktop where a session answers, otherwise Telegram",
|
|
"bursts are one message: what arrives within 30 s goes out together, a digest when it is several; urgent goes at once when the channel was quiet for 30 s",
|
|
"the desktop is gentle: warnings at most one message every 15 min",
|
|
"deduplicated by the condition's key; reminded once after 1 h (urgent) or 12 h (warning); cleared by editing the first message",
|
|
"at most 20 messages an hour per channel, a last line of defence: reached, it is said once and the rest held for one digest",
|
|
"refused: anything carrying an address, a path or a secret; channel-refused is sent with the words withheld",
|
|
"silenced conditions send nothing; silence is `conditions silence` through the mesh",
|
|
}
|
|
anyCan := false
|
|
for _, c := range st.Channels {
|
|
anyCan = anyCan || c.CanSend
|
|
}
|
|
switch {
|
|
case !anyCan:
|
|
st.Verdict = "CANNOT SEND: no channel can reach the operator — see channels"
|
|
case len(st.Unsent) > 0:
|
|
st.Verdict = "BEHIND: some messages could not be sent yet and are tried every minute"
|
|
case listening != "listening":
|
|
st.Verdict = "NOT LISTENING: condition events are not reaching this holder — " + listening
|
|
case st.StateProblem != "":
|
|
st.Verdict = "STATE: open messages are held in memory only — " + st.StateProblem
|
|
default:
|
|
st.Verdict = "ok"
|
|
for _, c := range st.Channels {
|
|
if !c.CanSend {
|
|
st.Verdict = "ok on one channel: " + c.Name + " cannot send"
|
|
}
|
|
}
|
|
}
|
|
return st
|
|
}
|
|
|
|
// OpenMessage is one open message as `open` answers it.
|
|
type OpenMessage struct {
|
|
Key string `json:"key"`
|
|
Severity string `json:"severity"`
|
|
Summary string `json:"summary"`
|
|
Subject string `json:"subject,omitempty"`
|
|
Since string `json:"since"`
|
|
Origin string `json:"origin"`
|
|
SentOn []string `json:"sent_on,omitempty"`
|
|
Silenced string `json:"silenced_until,omitempty"`
|
|
Reminded bool `json:"reminded"`
|
|
Unsent string `json:"unsent,omitempty"`
|
|
Held bool `json:"held,omitempty"`
|
|
History bool `json:"never_said,omitempty"`
|
|
Refused string `json:"refused,omitempty"`
|
|
Count int `json:"times_opened"`
|
|
}
|
|
|
|
// Open is what is unresolved now, urgent first, oldest first.
|
|
func (h *Holder) Open() []OpenMessage {
|
|
now := h.Now()
|
|
h.mu.Lock()
|
|
defer h.mu.Unlock()
|
|
queued := map[string]bool{}
|
|
for _, list := range h.outbox {
|
|
for _, it := range list {
|
|
queued[it.Key] = true
|
|
}
|
|
}
|
|
var out []OpenMessage
|
|
for _, r := range h.open {
|
|
if !r.Cleared.IsZero() {
|
|
continue
|
|
}
|
|
o := OpenMessage{Key: r.Key, Severity: r.Severity, Summary: r.Summary, Subject: r.Subject,
|
|
Since: r.Raised.UTC().Format(time.RFC3339), Origin: r.Origin, Reminded: r.Reminded,
|
|
Unsent: r.Pending, Held: r.Folded || queued[r.Key], History: r.History, Refused: r.Refused, Count: r.Count}
|
|
for ch := range r.Sent {
|
|
o.SentOn = append(o.SentOn, ch)
|
|
}
|
|
sort.Strings(o.SentOn)
|
|
if r.silenced(now) {
|
|
o.Silenced = r.SilencedTill.UTC().Format(time.RFC3339)
|
|
}
|
|
out = append(out, o)
|
|
}
|
|
sort.Slice(out, func(i, j int) bool {
|
|
if out[i].Severity != out[j].Severity {
|
|
return out[i].Severity == Urgent
|
|
}
|
|
return out[i].Since < out[j].Since
|
|
})
|
|
return out
|
|
}
|
|
|
|
// History is what went out, newest first, and the refusals.
|
|
func (h *Holder) History(limit int) map[string]any {
|
|
h.mu.Lock()
|
|
defer h.mu.Unlock()
|
|
sends := []Sent{}
|
|
for i := len(h.recent) - 1; i >= 0 && len(sends) < limit; i-- {
|
|
sends = append(sends, h.recent[i])
|
|
}
|
|
refusals := []RefusalNote{}
|
|
for i := len(h.refusals) - 1; i >= 0 && len(refusals) < limit; i-- {
|
|
refusals = append(refusals, h.refusals[i])
|
|
}
|
|
return map[string]any{"sends": sends, "refusals": refusals}
|
|
}
|
|
|
|
// Test sends a test message on one channel or both, through the content rule and the cap.
|
|
func (h *Holder) Test(channel, text string) map[string]string {
|
|
h.work.Lock()
|
|
defer h.work.Unlock()
|
|
if text == "" {
|
|
text = "a test from the operator-channel's holder: this channel reaches you"
|
|
}
|
|
out := map[string]string{}
|
|
if refusal, ok := Check(text); !ok {
|
|
out["refused"] = "it carried " + refusal.String() + "; nothing was sent"
|
|
return out
|
|
}
|
|
m := Message{Title: "Test message: " + text, Body: "Nothing is wrong; this was asked for."}
|
|
for _, ch := range h.channels() {
|
|
if channel != "" && channel != "both" && channel != ch.Name() {
|
|
continue
|
|
}
|
|
if err := ch.Ready(); err != nil {
|
|
out[ch.Name()] = "not sent: " + err.Error()
|
|
continue
|
|
}
|
|
if !h.allow(ch.Name()) {
|
|
out[ch.Name()] = "not sent: the channel is at its cap of 20 an hour"
|
|
continue
|
|
}
|
|
if h.sendNow(ch, "messenger.test", "test", m) {
|
|
out[ch.Name()] = "sent"
|
|
} else {
|
|
h.mu.Lock()
|
|
out[ch.Name()] = "failed: " + h.chanErr[ch.Name()]
|
|
h.mu.Unlock()
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Notify takes a message from a module that uses the seat (research 028 Q5, modules as sources):
|
|
// the same shape, rule, cap and deduplication as a condition. Its key is put under `notify.` so it
|
|
// can never be taken for one of the controller's conditions.
|
|
func (h *Holder) Notify(key, severity, summary, subject string, clear bool, plain ...string) (map[string]any, error) {
|
|
if key == "" {
|
|
return nil, errorf("key is required: the same key is the same message")
|
|
}
|
|
if len(key) < 7 || key[:7] != "notify." {
|
|
key = "notify." + key
|
|
}
|
|
h.work.Lock()
|
|
defer h.work.Unlock()
|
|
h.mu.Lock()
|
|
h.init()
|
|
h.mu.Unlock()
|
|
if clear {
|
|
h.cleared(key, false)
|
|
return map[string]any{"key": key, "cleared": true}, nil
|
|
}
|
|
switch severity {
|
|
case Urgent, Warning:
|
|
case "":
|
|
return nil, errorf("severity is required: urgent or warning")
|
|
default:
|
|
return nil, errorf("severity %q is neither urgent nor warning", severity)
|
|
}
|
|
if summary == "" {
|
|
return nil, errorf("summary is required: one line in the mesh's words")
|
|
}
|
|
rec := Record{Key: key, Kind: "notice", Subject: subject, Severity: severity, Summary: summary,
|
|
Origin: "notify", More: "operator-channel.open"}
|
|
// The plain words a module may give (novox/hq ADR 0253): a headline, an explanation, a resolved line.
|
|
for i, w := range plain {
|
|
switch i {
|
|
case 0:
|
|
rec.Headline = w
|
|
case 1:
|
|
rec.Explanation = w
|
|
case 2:
|
|
rec.Resolved = w
|
|
}
|
|
}
|
|
h.raised(rec, false)
|
|
h.mu.Lock()
|
|
r := h.open[key]
|
|
var sent []string
|
|
refused, pending := "", ""
|
|
if r != nil {
|
|
for ch := range r.Sent {
|
|
sent = append(sent, ch)
|
|
}
|
|
refused, pending = r.Refused, r.Pending
|
|
}
|
|
h.mu.Unlock()
|
|
sort.Strings(sent)
|
|
held := h.queued(key)
|
|
return map[string]any{"key": key, "sent_on": sent, "held": held, "refused": refused, "unsent": pending}, nil
|
|
}
|