CAT-MS1: the router says when an ask is offered and how it ended
offered once when an ask is offered, closed once for each of the five ends (answered, expired, cancelled by its asker, replaced, ended by the router), with the key, asker and headline and never an option, ticket or proof; on a stream of the events kept seven days, 64 MiB, granted by the trusted value readers, which names the knowledge base alone. Issue 725's runtime change is delivered (resolved 2026-10-26). Issue 1968. Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
This commit is contained in:
@@ -670,6 +670,7 @@ func (h *Holder) showAsk(key string, rec AskRecord, now time.Time) {
|
||||
target := h.open[rec.Record]
|
||||
h.mu.Unlock()
|
||||
h.Logf("[messenger] %s asks %q (%s), shown on %s", asker, a.Headline, a.ID, rec.Record)
|
||||
h.sayOffered(key, rec)
|
||||
if target != nil {
|
||||
h.mu.Lock()
|
||||
target.Ask = key
|
||||
@@ -917,6 +918,7 @@ func (h *Holder) closeAsk(key string, outcome asks.Outcome, words string, w *ask
|
||||
rev = newRev
|
||||
}
|
||||
h.keepAsk(key, rec, rev)
|
||||
h.sayClosed(key, rec, outcome, words)
|
||||
if rec.Asker == SelfAsker {
|
||||
// The router's own ask: it does what was decided, and tells nobody else (selfact.go).
|
||||
h.markTold(key)
|
||||
@@ -1416,3 +1418,55 @@ func (h *Holder) AsksNow() []AskView {
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// sayOffered says that an ask was offered, once: its id, asker, headline and the kinds of channel it was offered on; never an
|
||||
// option, a ticket or a warrant's proof, so it says no more than `asks` already answers (novox/hq ADR 0616 §7). A held ask is
|
||||
// said when it is released and shown, not before.
|
||||
func (h *Holder) sayOffered(key string, rec AskRecord) {
|
||||
if h.Emit == nil || rec.State != StateOpen {
|
||||
return
|
||||
}
|
||||
h.mu.Lock()
|
||||
if h.offeredSaid == nil {
|
||||
h.offeredSaid = map[string]bool{}
|
||||
}
|
||||
done := h.offeredSaid[key]
|
||||
h.offeredSaid[key] = true
|
||||
h.mu.Unlock()
|
||||
if done {
|
||||
return
|
||||
}
|
||||
kinds := map[string]bool{}
|
||||
for _, t := range rec.Tickets {
|
||||
kinds[t.Kind] = true
|
||||
}
|
||||
var on []string
|
||||
for k := range kinds {
|
||||
on = append(on, k)
|
||||
}
|
||||
sort.Strings(on)
|
||||
if err := h.Emit("offered", map[string]any{"key": key, "asker": rec.Asker, "headline": rec.Ask.Headline, "offered_on": on, "at": h.Now()}); err != nil {
|
||||
h.Logf("[messenger] that the ask %s was offered could not be said: %v", key, err)
|
||||
}
|
||||
}
|
||||
|
||||
// sayClosed says how an ask ended, once per ask (its end is recorded by compare-and-set, so a second close never gets here): answered,
|
||||
// expired, cancelled by its asker, replaced by a newer ask, or ended by the router itself on an unlink or allow-link.
|
||||
func (h *Holder) sayClosed(key string, rec AskRecord, outcome asks.Outcome, words string) {
|
||||
if h.Emit == nil {
|
||||
return
|
||||
}
|
||||
how := string(outcome)
|
||||
switch {
|
||||
case outcome == asks.OutcomeChosen:
|
||||
how = "answered"
|
||||
case outcome == asks.OutcomeCancelled && words != "its asker took it back":
|
||||
how = "ended by the router"
|
||||
}
|
||||
h.mu.Lock()
|
||||
delete(h.offeredSaid, key)
|
||||
h.mu.Unlock()
|
||||
if err := h.Emit("closed", map[string]any{"key": key, "asker": rec.Asker, "headline": rec.Ask.Headline, "how": how, "at": h.Now()}); err != nil {
|
||||
h.Logf("[messenger] that the ask %s ended could not be said: %v", key, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
)
|
||||
|
||||
// The router says when an ask starts and ends (ADR 0616 §7): `offered` once when an ask is offered, `closed` once for each of the five ends
|
||||
// (answered, expired, cancelled by its asker, replaced, ended by the router), with how it ended and never an option, a ticket or a
|
||||
// proof; a held ask is said when it is released.
|
||||
func TestTheRouterSaysWhenAnAskIsOfferedAndWhenItEnds(t *testing.T) {
|
||||
h, tg, _, c, store, _ := newRouter(t)
|
||||
link(h, store, c, "42", c.now().Add(-2*time.Hour))
|
||||
type said struct {
|
||||
event string
|
||||
body map[string]any
|
||||
}
|
||||
var got []said
|
||||
h.Emit = func(event string, body any) error {
|
||||
raw, _ := json.Marshal(body)
|
||||
var m map[string]any
|
||||
_ = json.Unmarshal(raw, &m)
|
||||
got = append(got, said{event, m})
|
||||
return nil
|
||||
}
|
||||
ends := func() (out []string) {
|
||||
for _, s := range got {
|
||||
if s.event == "closed" {
|
||||
out = append(out, s.body["key"].(string)+"="+s.body["how"].(string))
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
// Answered.
|
||||
submit(t, h, "mesh-delivery", heldAsk(c, "d-1"))
|
||||
settle(h, c)
|
||||
if len(got) != 1 || got[0].event != "offered" || got[0].body["key"] != "mesh-delivery.d-1" || got[0].body["asker"] != "mesh-delivery" {
|
||||
t.Fatalf("%+v", got)
|
||||
}
|
||||
release := ticketFor(tg.sends[0], "Release")
|
||||
if _, err := h.Chosen("telegram", choiceOf(release, "42", c.now())); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Replaced, then cancelled by its asker.
|
||||
a := ackAsk(c, "d-2")
|
||||
a.About = "delivery.hq"
|
||||
submit(t, h, "mesh-delivery", a)
|
||||
b := ackAsk(c, "d-3")
|
||||
b.About = "delivery.hq"
|
||||
submit(t, h, "mesh-delivery", b)
|
||||
if err := h.TakeAsk("cancel", "mesh-delivery", json.RawMessage(`{"id":"d-3"}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Expired.
|
||||
e := heldAsk(c, "d-4")
|
||||
e.About = "other"
|
||||
submit(t, h, "mesh-delivery", e)
|
||||
settle(h, c)
|
||||
c.pass(3 * time.Hour)
|
||||
stand(h, c)
|
||||
h.Tick()
|
||||
// Ended by the router itself (an unlink or allow-link ends its own asks the same way).
|
||||
f := ackAsk(c, "d-5")
|
||||
f.About = "third"
|
||||
submit(t, h, "mesh-delivery", f)
|
||||
if err := h.closeAsk("mesh-delivery.d-5", asks.OutcomeCancelled, "root decided to allow a link", nil, "", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := "mesh-delivery.d-1=answered mesh-delivery.d-2=replaced mesh-delivery.d-3=cancelled mesh-delivery.d-4=expired mesh-delivery.d-5=ended by the router"
|
||||
if gotEnds := strings.Join(ends(), " "); gotEnds != want {
|
||||
t.Fatalf("the ends %q, want %q", gotEnds, want)
|
||||
}
|
||||
// Once each: a second close loses and says nothing; five asks were offered, once each; no option, ticket or proof is in a body.
|
||||
_ = h.closeAsk("mesh-delivery.d-1", asks.OutcomeCancelled, "again", nil, "", nil)
|
||||
offered := 0
|
||||
for _, s := range got {
|
||||
if s.event == "offered" {
|
||||
offered++
|
||||
}
|
||||
for k := range s.body {
|
||||
if k == "options" || k == "tickets" || k == "token" || k == "proofs" || k == "warrant" {
|
||||
t.Errorf("%s carries %s", s.event, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
if offered != 5 || len(ends()) != 5 {
|
||||
t.Fatalf("%d offered, %d closed", offered, len(ends()))
|
||||
}
|
||||
}
|
||||
@@ -313,6 +313,8 @@ type Holder struct {
|
||||
Logf func(string, ...any)
|
||||
// Emit states a fact as this module (refused); nil states nothing.
|
||||
Emit func(event string, body any) error
|
||||
// offeredSaid are the asks whose offering was said, so it is said once.
|
||||
offeredSaid map[string]bool
|
||||
// Ask calls a seat verb through the mesh, as this module: the answers the operator chooses.
|
||||
Ask Asker
|
||||
// RootFree asks each machine's engine's root-free (the node seat node-engine's, ADR 0563 §6); nil asks it through Ask
|
||||
|
||||
@@ -94,7 +94,7 @@ func TestItDeclaresTheSeatsOfAnAskAndHoldsTheOperatorChannel(t *testing.T) { //
|
||||
}
|
||||
// It performs nothing: the verbs it calls read the open conditions, whether root is free, and which issues
|
||||
// track which conditions.
|
||||
if !reflect.DeepEqual(m.Emits, []string{"refused"}) || !reflect.DeepEqual(m.Invokes, []string{
|
||||
if !reflect.DeepEqual(m.Emits, []string{"refused", "offered", "closed"}) || !reflect.DeepEqual(m.Invokes, []string{
|
||||
"seat:mesh-conditions.conditions", "seat:" + EngineSeat + ".root-free", TrackingVerb}) {
|
||||
t.Fatalf("emits %v invokes %v", m.Emits, m.Invokes)
|
||||
}
|
||||
|
||||
@@ -2,5 +2,6 @@
|
||||
"time-zone": "",
|
||||
"only-what-needs-me": [],
|
||||
"messages-per-hour": 100,
|
||||
"settle-seconds": 120
|
||||
"settle-seconds": 120,
|
||||
"readers": {"offered": ["mesh-knowledge"], "closed": ["mesh-knowledge"]}
|
||||
}
|
||||
|
||||
@@ -86,7 +86,24 @@
|
||||
"intake.standing"
|
||||
],
|
||||
"emits": [
|
||||
"refused"
|
||||
"refused",
|
||||
"offered",
|
||||
"closed"
|
||||
],
|
||||
"streams": [
|
||||
{
|
||||
"name": "asks",
|
||||
"events": [
|
||||
"offered",
|
||||
"closed"
|
||||
],
|
||||
"max-age-days": 7,
|
||||
"max-mib": 64,
|
||||
"grant": {
|
||||
"config": "readers",
|
||||
"events": "{key}"
|
||||
}
|
||||
}
|
||||
],
|
||||
"invokes": [
|
||||
"seat:mesh-conditions.conditions",
|
||||
@@ -117,6 +134,12 @@
|
||||
"messenger_unlink"
|
||||
],
|
||||
"config": {
|
||||
"readers": {
|
||||
"type": "object",
|
||||
"description": "Who may hear an ask offered or closed (the stream of the asks, kept seven days), per event: offered and closed, each a list of module names, on every machine. The knowledge base alone by default, for its overview page; an asker's own answers (decided) are never on it (ADR 0616 §7).",
|
||||
"trusted": true,
|
||||
"default": {"offered": ["mesh-knowledge"], "closed": ["mesh-knowledge"]}
|
||||
},
|
||||
"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.",
|
||||
@@ -173,7 +196,7 @@
|
||||
"type": "file",
|
||||
"path": "${dir:state}/settings.json",
|
||||
"mode": "0600",
|
||||
"takes-config": ["time-zone", "desktop-machines", "only-what-needs-me", "messages-per-hour", "settle-seconds"],
|
||||
"takes-config": ["time-zone", "desktop-machines", "only-what-needs-me", "messages-per-hour", "settle-seconds", "readers"],
|
||||
"merge": "json",
|
||||
"template": "files/settings.json",
|
||||
"owner": "messenger"
|
||||
|
||||
Reference in New Issue
Block a user