Join item 1: the catalogue's modules take the router's ask from operator-channel's contract and client
mesh-sdk holds neither asks nor asker, nor the vault's answer types, and mesh-delivery's sdk/views is mesh-sdk's go/views (a1-progress.md, the rewrite map for the join): thirteen modules switch to the operatorchannel contract and client, mesh-sdk's channel package and meshvault, with go.mod requiring each at the group pin (nats: meshbroker too). The gate holds the checks mesh-sdk dropped (an ask's id is its key's asking, at least one option), with a row that fails without them; claude-code refuses a router record whose times do not parse, as its JSON decode did, with a row. Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
This commit is contained in:
@@ -25,7 +25,8 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
|
||||
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
|
||||
)
|
||||
|
||||
// ForgeProvision is the provision whose credential this is, and the file this module's manifest has it delivered to.
|
||||
@@ -55,16 +56,16 @@ func (f *ForgeHealth) key() string { return "claude-code.machine." + f.Node + ".
|
||||
|
||||
// Look is the condition to say after one placing of the credential, nil when nothing changed: raised on a failure, again
|
||||
// when its reason changes, cleared when it places again; on the first look a success clears what a run before raised.
|
||||
func (f *ForgeHealth) Look(err error) *asks.Condition {
|
||||
func (f *ForgeHealth) Look(err error) *oc.Condition {
|
||||
first := !f.looked
|
||||
f.looked = true
|
||||
subject := asks.Scope{Scope: "machine", ID: f.Node, Machine: f.Node}
|
||||
subject := oc.ConditionSubject{Scope: "machine", ID: f.Node, Machine: f.Node}
|
||||
if err == nil {
|
||||
if f.raised == "" && !first {
|
||||
return nil
|
||||
}
|
||||
f.raised = ""
|
||||
return &asks.Condition{Key: f.key(), Subject: subject, State: asks.StateCleared}
|
||||
return &oc.Condition{Key: f.key(), Subject: subject, State: occ.StateCleared}
|
||||
}
|
||||
why := err.Error()
|
||||
if why == f.raised {
|
||||
@@ -72,10 +73,10 @@ func (f *ForgeHealth) Look(err error) *asks.Condition {
|
||||
}
|
||||
f.raised = why
|
||||
needs := "read claude-code's journal on " + f.Node + " and the owner of its forge credential file"
|
||||
return &asks.Condition{Key: f.key(), Severity: asks.SeverityWarning, State: asks.StateRaised, Subject: subject,
|
||||
return &oc.Condition{Key: f.key(), Severity: occ.SeverityWarning, State: occ.StateRaised, Subject: subject,
|
||||
Headline: "Agents on " + f.Node + " cannot use the forge credential",
|
||||
Summary: why, Needs: needs,
|
||||
Explanation: asks.NeedsYou + needs + ". " + sentence(why) + " Until it is placed, an agent there cannot " +
|
||||
Explanation: occ.NeedsYou + needs + ". " + sentence(why) + " Until it is placed, an agent there cannot " +
|
||||
"clone or fetch the mesh's private repositories.",
|
||||
Resolved: "The forge's read credential is placed again on " + f.Node + "."}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
|
||||
)
|
||||
|
||||
const agentToken = "0123456789abcdef0123456789abcdef01234567"
|
||||
@@ -316,7 +316,7 @@ func TestTheForgeCredentialIsTheRuntimesAndItsFailureIsAConditionOfTheModule(t *
|
||||
got := ""
|
||||
if c != nil {
|
||||
got = c.State
|
||||
if _, err := asks.EncodeCondition(*c); err != nil {
|
||||
if _, err := occ.EncodeCondition(*c); err != nil {
|
||||
t.Errorf("look %d: the condition is refused: %v", i, err)
|
||||
}
|
||||
if c.Key != "claude-code.machine.anchor.forge-credential" {
|
||||
|
||||
@@ -22,9 +22,10 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
|
||||
"git.novox.be/novox/mesh-contracts/go/seats"
|
||||
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/handacts"
|
||||
"git.novox.be/novox/mesh-sdk/go/views"
|
||||
@@ -184,12 +185,12 @@ func (s memoryStateOf) Delete(key string) error { return s.s.Delete(key) }
|
||||
var memoryCarrier *MemoryCarrier
|
||||
|
||||
// sayCondition says a condition on the operator-channel seat under this module's name (novox/hq ADR 0437 §9).
|
||||
func sayCondition(c asks.Condition) error {
|
||||
subject, err := asks.ConditionSubject("claude-code")
|
||||
func sayCondition(c oc.Condition) error {
|
||||
subject, err := occ.ConditionSubject("claude-code")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw, err := asks.EncodeCondition(c)
|
||||
raw, err := occ.EncodeCondition(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -9,8 +9,8 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
)
|
||||
|
||||
// Proposing is what the proposal tools reach: the `proposals` state, the operator channel, the controller's
|
||||
@@ -71,9 +71,13 @@ func (d *Proposing) watch(p Paths) {
|
||||
}
|
||||
}
|
||||
go persist("hearing the operator's answers", func() error {
|
||||
return stdio.Subscribe(asks.Seat+".decided."+Module, func(e stdio.Envelope) error {
|
||||
var w asks.Warrant
|
||||
if json.Unmarshal(e.Body, &w) == nil {
|
||||
return stdio.Subscribe(oc.Seat+".decided."+Module, func(e stdio.Envelope) error {
|
||||
var w oc.Warrant
|
||||
if err := json.Unmarshal(e.Body, &w); err == nil {
|
||||
if _, err := warrantAt(w); err != nil {
|
||||
say("not taking the router's word on %s: %v", w.Ask, err)
|
||||
return nil
|
||||
}
|
||||
if strings.HasPrefix(w.Ask, QuestionPrefix) {
|
||||
HeardQuestionEnd(d.questions(), w)
|
||||
return nil
|
||||
|
||||
@@ -15,7 +15,7 @@ package main
|
||||
// and lets this module read only its own asks);
|
||||
// 2. composes the ask again from the proposal — the words, the options, the expiry, the digest of the text —
|
||||
// and holds the router's warrant to it: the same ask digest, an option it offered, at its level, in time
|
||||
// (asks.Warrant.For), and the option's bound act is this proposal's (asks.Option.Performs). An ask published
|
||||
// (oc.Warrant.For), and the option's bound act is this proposal's (oc.Option.Performs). An ask published
|
||||
// under this module's name with other words, or a proposal changed after it was asked, finds the digests
|
||||
// differ and nothing is done;
|
||||
// 3. applies the approved text to its own file, `approved.json` in the module's state directory, which is what
|
||||
@@ -42,8 +42,9 @@ import (
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
|
||||
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
)
|
||||
|
||||
// Module is this module's name: the one the bus grants its asks under.
|
||||
@@ -68,7 +69,7 @@ const RouterKeeps = 30 * 24 * time.Hour
|
||||
|
||||
// ProposalLasts is how long the operator has to answer: a day less a margin, so the router never refuses it for
|
||||
// lasting a day and a moment (the SDK's bound for an approval).
|
||||
const ProposalLasts = asks.ApproveLasts - 10*time.Minute
|
||||
const ProposalLasts = occ.ApproveLasts - 10*time.Minute
|
||||
|
||||
// What a proposal does.
|
||||
const (
|
||||
@@ -164,8 +165,8 @@ func joinAnd(list []string) string {
|
||||
|
||||
// Act is what an answer binds: this proposal, whole, and the answer. The router's warrant names the ask's digest,
|
||||
// and the ask names this act's digest in the option, so an approval authorises this text and nothing else.
|
||||
func (p Proposal) Act(option string) asks.Act {
|
||||
return asks.Act{"verb": Module + ".instructions", "option": option, "proposal": p.ID, "action": p.Action,
|
||||
func (p Proposal) Act(option string) occ.Act {
|
||||
return occ.Act{"verb": Module + ".instructions", "option": option, "proposal": p.ID, "action": p.Action,
|
||||
"scope": p.Scope, "nodes": strings.Join(p.Nodes, ","), "name": p.Name, "digest": p.Digest, "from": p.From,
|
||||
"at": p.At.UTC().Format(time.RFC3339Nano)}
|
||||
}
|
||||
@@ -175,7 +176,7 @@ const AskGlance = 1
|
||||
|
||||
// Ask is the ask for a proposal, composed from it alone: every node composes the same one, and holds the
|
||||
// router's warrant to it — in the form it was asked in.
|
||||
func (p Proposal) Ask() asks.Ask {
|
||||
func (p Proposal) Ask() oc.Ask {
|
||||
if p.Form == AskGlance {
|
||||
return p.glanceAsk()
|
||||
}
|
||||
@@ -185,7 +186,7 @@ func (p Proposal) Ask() asks.Ask {
|
||||
// glanceAsk is the ask read at a glance on the phone (novox/hq issue 639): its headline, and as its explanation
|
||||
// the summary of what it does; the text, where it came from, its fingerprint and where to read all of it are its
|
||||
// Details, which the router notes after the answers, collapsed where the channel can.
|
||||
func (p Proposal) glanceAsk() asks.Ask {
|
||||
func (p Proposal) glanceAsk() oc.Ask {
|
||||
verb := map[string]string{ActionAdd: "Add", ActionReplace: "Replace", ActionRemove: "Remove"}[p.Action]
|
||||
shown, whole := excerpt(p.Text)
|
||||
part := ""
|
||||
@@ -193,7 +194,7 @@ func (p Proposal) glanceAsk() asks.Ask {
|
||||
part = " Part shown"
|
||||
}
|
||||
headline := fmt.Sprintf("%s Claude instructions '%s'?%s", verb, p.Name, part)
|
||||
if len([]rune(headline)) > asks.HeadlineLength {
|
||||
if len([]rune(headline)) > occ.HeadlineLength {
|
||||
headline = verb + " Claude instructions?" + part
|
||||
}
|
||||
var d strings.Builder
|
||||
@@ -214,7 +215,7 @@ func (p Proposal) glanceAsk() asks.Ask {
|
||||
if p.Action == ActionRemove {
|
||||
does = "the instructions are removed from those sessions"
|
||||
}
|
||||
return p.withOptions(asks.Ask{ID: p.ID, Headline: headline, Explanation: p.summary(), Details: d.String()}, does)
|
||||
return p.withOptions(oc.Ask{ID: p.ID, Headline: headline, Explanation: p.summary(), Details: d.String()}, does)
|
||||
}
|
||||
|
||||
// summary is what the proposal does, at a glance: its proposer's words, or the module's.
|
||||
@@ -244,8 +245,8 @@ func SummaryProblem(summary string) string {
|
||||
return ""
|
||||
case strings.Contains(s, "\n"):
|
||||
return "the summary is more than one line"
|
||||
case len([]rune(s)) > asks.SummaryLength:
|
||||
return fmt.Sprintf("the summary is %d characters, more than the %d a phone shows at a glance", len([]rune(s)), asks.SummaryLength)
|
||||
case len([]rune(s)) > occ.SummaryLength:
|
||||
return fmt.Sprintf("the summary is %d characters, more than the %d a phone shows at a glance", len([]rune(s)), occ.SummaryLength)
|
||||
case !shownLine(s):
|
||||
return "the summary names a path, an address, a host name or a long word, which a phone is not shown"
|
||||
}
|
||||
@@ -254,8 +255,8 @@ func SummaryProblem(summary string) string {
|
||||
|
||||
// withOptions is an ask with what every form of it carries: who answers, its expiry, what it is about, and its two
|
||||
// options, each binding its act.
|
||||
func (p Proposal) withOptions(a asks.Ask, does string) asks.Ask {
|
||||
a.Who, a.Expires, a.OnExpiry = asks.Operator, p.At.Add(ProposalLasts), "the proposal is discarded; nothing changes"
|
||||
func (p Proposal) withOptions(a oc.Ask, does string) oc.Ask {
|
||||
a.Who, a.Expires, a.OnExpiry = occ.Operator, occ.FormatTime(p.At.Add(ProposalLasts)), "the proposal is discarded; nothing changes"
|
||||
a.About = "instructions." + p.Scope + "." + strings.Join(p.Nodes, "+") + "." + p.Name
|
||||
if len(p.Nodes) == 0 {
|
||||
a.About = "instructions." + p.Scope + "." + p.Name
|
||||
@@ -264,15 +265,15 @@ func (p Proposal) withOptions(a asks.Ask, does string) asks.Ask {
|
||||
{OptionApprove, "Approve", does},
|
||||
{OptionDecline, "Decline", "nothing changes; the proposal is discarded"},
|
||||
} {
|
||||
binds, _ := asks.ActDigest(p.Act(o.id))
|
||||
a.Options = append(a.Options, asks.Option{ID: o.id, Label: o.label, Does: o.does, Level: asks.Approve, Binds: binds})
|
||||
binds, _ := occ.ActDigest(p.Act(o.id))
|
||||
a.Options = append(a.Options, oc.Option{ID: o.id, Label: o.label, Does: o.does, Level: occ.Approve, Binds: binds})
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// listedAsk is the ask as it was composed before issue 639, the text in its explanation: composed only to hold the
|
||||
// warrant of a proposal made then.
|
||||
func (p Proposal) listedAsk() asks.Ask {
|
||||
func (p Proposal) listedAsk() oc.Ask {
|
||||
verb := map[string]string{ActionAdd: "Add", ActionReplace: "Replace", ActionRemove: "Remove"}[p.Action]
|
||||
shown, whole := excerpt(p.Text)
|
||||
part := ""
|
||||
@@ -280,7 +281,7 @@ func (p Proposal) listedAsk() asks.Ask {
|
||||
part = " Part shown"
|
||||
}
|
||||
headline := fmt.Sprintf("%s Claude instructions '%s'?%s", verb, p.Name, part)
|
||||
if len([]rune(headline)) > asks.HeadlineLength {
|
||||
if len([]rune(headline)) > occ.HeadlineLength {
|
||||
headline = verb + " Claude instructions?" + part
|
||||
}
|
||||
var b strings.Builder
|
||||
@@ -311,8 +312,8 @@ func (p Proposal) listedAsk() asks.Ask {
|
||||
if p.Action == ActionRemove {
|
||||
does = "the instructions are removed from those sessions"
|
||||
}
|
||||
a := asks.Ask{ID: p.ID, Headline: headline, Explanation: strings.TrimRight(b.String(), "\n"), Who: asks.Operator,
|
||||
Expires: p.At.Add(ProposalLasts), OnExpiry: "the proposal is discarded; nothing changes",
|
||||
a := oc.Ask{ID: p.ID, Headline: headline, Explanation: strings.TrimRight(b.String(), "\n"), Who: occ.Operator,
|
||||
Expires: occ.FormatTime(p.At.Add(ProposalLasts)), OnExpiry: "the proposal is discarded; nothing changes",
|
||||
About: "instructions." + p.Scope + "." + strings.Join(p.Nodes, "+") + "." + p.Name}
|
||||
if len(p.Nodes) == 0 {
|
||||
a.About = "instructions." + p.Scope + "." + p.Name
|
||||
@@ -321,8 +322,8 @@ func (p Proposal) listedAsk() asks.Ask {
|
||||
{OptionApprove, "Approve", does},
|
||||
{OptionDecline, "Decline", "nothing changes; the proposal is discarded"},
|
||||
} {
|
||||
binds, _ := asks.ActDigest(p.Act(o.id))
|
||||
a.Options = append(a.Options, asks.Option{ID: o.id, Label: o.label, Does: o.does, Level: asks.Approve, Binds: binds})
|
||||
binds, _ := occ.ActDigest(p.Act(o.id))
|
||||
a.Options = append(a.Options, oc.Option{ID: o.id, Label: o.label, Does: o.does, Level: occ.Approve, Binds: binds})
|
||||
}
|
||||
return a
|
||||
}
|
||||
@@ -433,7 +434,7 @@ func invisible(text string) string {
|
||||
// write the record and ask for it under this module's name.
|
||||
func ProposalProblem(p Proposal) string {
|
||||
switch {
|
||||
case !asks.UsableID(p.ID):
|
||||
case !occ.UsableID(p.ID):
|
||||
return "its id is not one"
|
||||
case !itemName.MatchString(p.Name):
|
||||
return "its name is not one"
|
||||
@@ -838,21 +839,21 @@ func Propose(p Paths, in ProposeInput, store ProposalStore, bus AskBus, now time
|
||||
if why := approverReady(bus); why != "" {
|
||||
return refuse(why + "; " + AtTheTerminalInstead)
|
||||
}
|
||||
if waiting := waitingProposals(store, approved, now); waiting >= asks.MostOpen {
|
||||
if waiting := waitingProposals(store, approved, now); waiting >= occ.MostOpen {
|
||||
return refuse(fmt.Sprintf("%d proposals already wait for the operator's answer, and the operator is asked at most %d "+
|
||||
"questions at once; claude_code_proposals lists them", waiting, asks.MostOpen))
|
||||
"questions at once; claude_code_proposals lists them", waiting, occ.MostOpen))
|
||||
}
|
||||
if problem := ProposalProblem(prop); problem != "" {
|
||||
return refuse(problem)
|
||||
}
|
||||
ask := prop.Ask()
|
||||
if err := ask.Check(now); err != nil {
|
||||
if err := occ.Check(ask, now); err != nil {
|
||||
return refuse(err.Error())
|
||||
}
|
||||
if err := store.Create(prop.ID, prop); err != nil {
|
||||
return nil, fmt.Errorf("the proposal could not be kept, so the operator was not asked: %w", err)
|
||||
}
|
||||
if err := bus.Publish(asks.AskSubject(Module), ask, ask.ID); err != nil {
|
||||
if err := bus.Publish(oc.AcceptSubjectAsk(Module), ask, ask.ID); err != nil {
|
||||
prop.Asked = "not asked: " + err.Error()
|
||||
_ = store.Put(prop.ID, prop)
|
||||
say("proposal %s (%s %s %s) could not be asked: %v", prop.ID, prop.Action, prop.Scope, prop.Name, err)
|
||||
@@ -874,11 +875,15 @@ func Propose(p Paths, in ProposeInput, store ProposalStore, bus AskBus, now time
|
||||
}
|
||||
time.Sleep(RouterAnswersEvery)
|
||||
}
|
||||
expires, err := askExpires(ask)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{
|
||||
"router": taken,
|
||||
"proposed": prop.ID, "action": prop.Action, "reaches": prop.where(), "fingerprint": prop.Fingerprint(),
|
||||
"asked": "the operator is asked on a channel that proves who answers: \"" + ask.Headline + "\"",
|
||||
"expires": ask.Expires.Format(time.RFC3339),
|
||||
"expires": expires.Format(time.RFC3339),
|
||||
"until then": "nothing changes on any node; the text becomes part of those sessions only on the operator's Approve, " +
|
||||
"and is discarded on Decline or at expiry. claude_code_proposals shows where it stands",
|
||||
}, nil
|
||||
@@ -927,10 +932,10 @@ var (
|
||||
// read.
|
||||
var (
|
||||
endedMu sync.Mutex
|
||||
ended = map[string]asks.Warrant{}
|
||||
ended = map[string]oc.Warrant{}
|
||||
)
|
||||
|
||||
func endedAsk(id string) (asks.Warrant, bool) {
|
||||
func endedAsk(id string) (oc.Warrant, bool) {
|
||||
endedMu.Lock()
|
||||
defer endedMu.Unlock()
|
||||
w, ok := ended[id]
|
||||
@@ -940,8 +945,8 @@ func endedAsk(id string) (asks.Warrant, bool) {
|
||||
// HeardEnd takes the router's word that one of this module's asks ended without a choice: refused, expired,
|
||||
// cancelled or replaced. The proposal is discarded here at once. Nothing is applied on such a word, so it needs no
|
||||
// proof beyond its subject; a choice is only ever taken from the router's record (Reconcile).
|
||||
func HeardEnd(p Paths, store ProposalStore, w asks.Warrant, now time.Time) (string, error) {
|
||||
if w.Asker != Module || w.Outcome == asks.OutcomeChosen || !asks.UsableID(w.Ask) {
|
||||
func HeardEnd(p Paths, store ProposalStore, w oc.Warrant, now time.Time) (string, error) {
|
||||
if w.Asker != Module || w.Outcome == occ.OutcomeChosen || !occ.UsableID(w.Ask) {
|
||||
return "", nil
|
||||
}
|
||||
reconcileMu.Lock()
|
||||
@@ -968,7 +973,7 @@ func HeardEnd(p Paths, store ProposalStore, w asks.Warrant, now time.Time) (stri
|
||||
}
|
||||
// Heard, not read from a record: Reconcile still reads the router's record of it, so an ask the router took after
|
||||
// all (its queue offered it again once a channel was ready) is answered as any other.
|
||||
d := ProposalDecision{At: now.UTC(), Says: w.Says(), Outcome: "discarded: the ask " + string(w.Outcome), Heard: true}
|
||||
d := ProposalDecision{At: now.UTC(), Says: occ.Says(w), Outcome: "discarded: the ask " + string(w.Outcome), Heard: true}
|
||||
if w.Words != "" {
|
||||
d.Outcome += " (" + w.Words + ")"
|
||||
}
|
||||
@@ -986,9 +991,9 @@ func HeardEnd(p Paths, store ProposalStore, w asks.Warrant, now time.Time) (stri
|
||||
|
||||
// routerRecord is the router's record of one ask, as far as this module reads it.
|
||||
type routerRecord struct {
|
||||
State string `json:"state"`
|
||||
Ask *asks.Ask `json:"ask"`
|
||||
Warrant *asks.Warrant `json:"warrant"`
|
||||
State string `json:"state"`
|
||||
Ask *oc.Ask `json:"ask"`
|
||||
Warrant *oc.Warrant `json:"warrant"`
|
||||
}
|
||||
|
||||
var reconcileMu sync.Mutex
|
||||
@@ -1010,7 +1015,7 @@ func Reconcile(p Paths, store ProposalStore, bus AskBus, record Recorder, now ti
|
||||
}
|
||||
type closed struct {
|
||||
prop Proposal
|
||||
w asks.Warrant
|
||||
w oc.Warrant
|
||||
}
|
||||
var ready []closed
|
||||
discarded := false
|
||||
@@ -1027,8 +1032,8 @@ func Reconcile(p Paths, store ProposalStore, bus AskBus, record Recorder, now ti
|
||||
if done {
|
||||
// Discarded on the router's word alone: only a record of a person's choice reopens it.
|
||||
var rec routerRecord
|
||||
if err == nil && entry != nil && json.Unmarshal(entry.Value, &rec) == nil && rec.Warrant != nil &&
|
||||
rec.Warrant.Outcome == asks.OutcomeChosen {
|
||||
if err == nil && entry != nil && json.Unmarshal(entry.Value, &rec) == nil && rec.check() == nil && rec.Warrant != nil &&
|
||||
rec.Warrant.Outcome == occ.OutcomeChosen {
|
||||
ready = append(ready, closed{prop, *rec.Warrant})
|
||||
}
|
||||
continue
|
||||
@@ -1046,12 +1051,16 @@ func Reconcile(p Paths, store ProposalStore, bus AskBus, record Recorder, now ti
|
||||
continue // open, not yet recorded, or not readable now: asked again on the next look
|
||||
}
|
||||
var rec routerRecord
|
||||
if json.Unmarshal(entry.Value, &rec) != nil || rec.State == "open" || rec.Warrant == nil {
|
||||
if json.Unmarshal(entry.Value, &rec) != nil || rec.check() != nil || rec.State == "open" || rec.Warrant == nil {
|
||||
continue
|
||||
}
|
||||
ready = append(ready, closed{prop, *rec.Warrant})
|
||||
}
|
||||
sort.SliceStable(ready, func(i, j int) bool { return ready[i].w.At.Before(ready[j].w.At) })
|
||||
sort.SliceStable(ready, func(i, j int) bool {
|
||||
a, _ := warrantAt(ready[i].w) // checked when read
|
||||
b, _ := warrantAt(ready[j].w)
|
||||
return a.Before(b)
|
||||
})
|
||||
changed, wrote := false, discarded
|
||||
var said []string
|
||||
for _, c := range ready {
|
||||
@@ -1108,17 +1117,21 @@ func Reconcile(p Paths, store ProposalStore, bus AskBus, record Recorder, now ti
|
||||
|
||||
// decide holds one closed ask's warrant to its proposal, and applies it to this node's record when it holds. final
|
||||
// is false when the answer did not hold here: it is judged again on the next look, and nothing is applied.
|
||||
func decide(p Paths, approved *Approved, prop Proposal, w asks.Warrant, now time.Time) (ProposalDecision, bool, string, bool) {
|
||||
d := ProposalDecision{At: now.UTC(), Says: w.Says()}
|
||||
func decide(p Paths, approved *Approved, prop Proposal, w oc.Warrant, now time.Time) (ProposalDecision, bool, string, bool) {
|
||||
d := ProposalDecision{At: now.UTC(), Says: occ.Says(w)}
|
||||
refused := func(why string) (ProposalDecision, bool, string, bool) {
|
||||
d.Outcome = "refused: " + why
|
||||
say("REFUSED the answer to proposal %s: %s", prop.ID, why)
|
||||
return d, false, "proposal " + prop.ID + ": refused, " + why, false
|
||||
}
|
||||
at, err := warrantAt(w)
|
||||
if err != nil {
|
||||
return refused(err.Error())
|
||||
}
|
||||
if w.Asker != Module || w.Ask != prop.ID {
|
||||
return refused(fmt.Sprintf("the router's record is for %s's ask %s", w.Asker, w.Ask))
|
||||
}
|
||||
if w.Outcome != asks.OutcomeChosen {
|
||||
if w.Outcome != occ.OutcomeChosen {
|
||||
d.Outcome = "discarded: the ask " + string(w.Outcome)
|
||||
if w.Words != "" {
|
||||
d.Outcome += " (" + w.Words + ")"
|
||||
@@ -1130,11 +1143,11 @@ func decide(p Paths, approved *Approved, prop Proposal, w asks.Warrant, now time
|
||||
if problem := ProposalProblem(prop); problem != "" {
|
||||
return refused("the proposal is not one this module makes: " + problem)
|
||||
}
|
||||
option, err := w.For(Module, prop.Ask())
|
||||
option, err := occ.For(w, Module, prop.Ask())
|
||||
if err != nil {
|
||||
return refused(err.Error())
|
||||
}
|
||||
if err := option.Performs(prop.Act(option.ID)); err != nil {
|
||||
if err := occ.Performs(option, prop.Act(option.ID)); err != nil {
|
||||
return refused(err.Error())
|
||||
}
|
||||
d.Warranted = true
|
||||
@@ -1146,7 +1159,7 @@ func decide(p Paths, approved *Approved, prop Proposal, w asks.Warrant, now time
|
||||
return refused("the option " + option.ID + " is not one a proposal offers")
|
||||
}
|
||||
key := sectionKey(prop.Scope, prop.Nodes, prop.Name)
|
||||
if last, ok := approved.Changed[key]; ok && !w.At.After(last) {
|
||||
if last, ok := approved.Changed[key]; ok && !at.After(last) {
|
||||
d.Outcome = "approved, and superseded here: a change of this section approved later was applied first"
|
||||
return d, false, "proposal " + prop.ID + ": " + d.Outcome, true
|
||||
}
|
||||
@@ -1157,16 +1170,16 @@ func decide(p Paths, approved *Approved, prop Proposal, w asks.Warrant, now time
|
||||
held, ok := approved.Sections[key]
|
||||
if !ok || held.Digest != prop.Digest {
|
||||
d.Outcome = "approved, nothing to remove here: this node holds no section " + prop.Name + " with that fingerprint"
|
||||
approved.Changed[key] = w.At.UTC()
|
||||
approved.Changed[key] = at.UTC()
|
||||
return d, false, "proposal " + prop.ID + ": " + d.Outcome, true
|
||||
}
|
||||
delete(approved.Sections, key)
|
||||
default:
|
||||
approved.Sections[key] = ApprovedSection{Name: prop.Name, Scope: prop.Scope, Nodes: prop.Nodes, Text: prop.Text,
|
||||
Digest: prop.Digest, Proposal: prop.ID, From: prop.From, Approved: w.At.UTC(), By: w.Says()}
|
||||
Digest: prop.Digest, Proposal: prop.ID, From: prop.From, Approved: at.UTC(), By: occ.Says(w)}
|
||||
}
|
||||
approved.Changed[key] = w.At.UTC()
|
||||
say("proposal %s (%s %s %s, fingerprint %s): %s", prop.ID, prop.Action, prop.Scope, prop.Name, prop.Fingerprint(), w.Says())
|
||||
approved.Changed[key] = at.UTC()
|
||||
say("proposal %s (%s %s %s, fingerprint %s): %s", prop.ID, prop.Action, prop.Scope, prop.Name, prop.Fingerprint(), occ.Says(w))
|
||||
return d, reaches, "proposal " + prop.ID + ": approved, " + prop.Action + " " + prop.Name, true
|
||||
}
|
||||
|
||||
@@ -1222,12 +1235,12 @@ func ListProposals(p Paths, store ProposalStore, bus AskBus, id string, now time
|
||||
// the router took, both read now.
|
||||
if entry, err := bus.Record(prop.ID); err == nil && entry != nil {
|
||||
var rec routerRecord
|
||||
if json.Unmarshal(entry.Value, &rec) == nil {
|
||||
if json.Unmarshal(entry.Value, &rec) == nil && rec.check() == nil {
|
||||
row["router"] = rec.State
|
||||
switch {
|
||||
case rec.Ask == nil:
|
||||
row["the phone"] = "cannot be compared: the router's record keeps no ask"
|
||||
case rec.Ask.Digest() == prop.Ask().Digest():
|
||||
case occ.Digest(*rec.Ask) == occ.Digest(prop.Ask()):
|
||||
row["the phone"] = "shows the ask composed from exactly this text (fingerprint " + prop.Fingerprint() + ")"
|
||||
default:
|
||||
row["the phone"] = "shows ANOTHER ask than this record composes: do not approve it"
|
||||
|
||||
@@ -13,8 +13,9 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
|
||||
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
)
|
||||
|
||||
// memProposals is the `proposals` state, shared by every node of a test as the bus shares it.
|
||||
@@ -46,7 +47,7 @@ func (s *memProposals) Get(key string) (json.RawMessage, bool, error) {
|
||||
// router is the operator channel's holder as the module reaches it: what was asked, its record of each ask, and
|
||||
// the channel kinds.
|
||||
type router struct {
|
||||
published map[string]asks.Ask
|
||||
published map[string]oc.Ask
|
||||
subjects []string
|
||||
records map[string]json.RawMessage
|
||||
kinds []stdio.KindHeld
|
||||
@@ -54,7 +55,7 @@ type router struct {
|
||||
}
|
||||
|
||||
func newRouter() *router {
|
||||
return &router{published: map[string]asks.Ask{}, records: map[string]json.RawMessage{},
|
||||
return &router{published: map[string]oc.Ask{}, records: map[string]json.RawMessage{},
|
||||
kinds: []stdio.KindHeld{{Seat: "channel", Kind: "telegram", Module: "telegram", Node: "novox",
|
||||
Capabilities: []string{"choice", "exact-render", "verified-sender"}}}}
|
||||
}
|
||||
@@ -65,7 +66,7 @@ func (r *router) Publish(subject string, body any, id string) error {
|
||||
}
|
||||
r.subjects = append(r.subjects, subject)
|
||||
raw, _ := json.Marshal(body)
|
||||
var a asks.Ask
|
||||
var a oc.Ask
|
||||
_ = json.Unmarshal(raw, &a)
|
||||
r.published[a.ID] = a
|
||||
r.records[a.ID], _ = json.Marshal(map[string]any{"state": "open"})
|
||||
@@ -81,19 +82,19 @@ func (r *router) Record(id string) (*stdio.StateEntry, error) {
|
||||
func (r *router) Kinds() ([]stdio.KindHeld, error) { return r.kinds, nil }
|
||||
|
||||
// close ends an ask with a warrant, as the router records it.
|
||||
func (r *router) close(w asks.Warrant) {
|
||||
func (r *router) close(w oc.Warrant) {
|
||||
w.Asker = Module
|
||||
state := string(w.Outcome)
|
||||
r.records[w.Ask], _ = json.Marshal(map[string]any{"state": state, "warrant": w})
|
||||
}
|
||||
|
||||
// chose is the warrant for a person's choice of an option of the ask as the router took it.
|
||||
func (r *router) chose(id, option string, at time.Time) asks.Warrant {
|
||||
func (r *router) chose(id, option string, at time.Time) oc.Warrant {
|
||||
a := r.published[id]
|
||||
o, _ := a.Option(option)
|
||||
return asks.Warrant{Ask: id, Asker: Module, Outcome: asks.OutcomeChosen, Option: o.ID, Label: o.Label, Level: o.Level,
|
||||
By: &asks.Person{Who: asks.Operator, Kind: "telegram", Identity: "42", Verified: "user id verified"},
|
||||
Channel: "telegram", Proofs: []string{"P1"}, At: at, AskDigest: a.Digest()}
|
||||
o, _ := occ.OptionOf(a, option)
|
||||
return oc.Warrant{Ask: id, Asker: Module, Outcome: occ.OutcomeChosen, Option: o.ID, Label: o.Label, Level: o.Level,
|
||||
By: &oc.Person{Who: occ.Operator, Kind: "telegram", Identity: "42", Verified: "user id verified"},
|
||||
Channel: "telegram", Proofs: []string{"P1"}, At: occ.FormatTime(at), AskDigest: occ.Digest(a)}
|
||||
}
|
||||
|
||||
type recorded struct{ asks []string }
|
||||
@@ -144,13 +145,13 @@ func TestAProposalAsksTheOperatorAtTheLevelApproveAndChangesNothing(t *testing.T
|
||||
t.Fatalf("the ask was published on %v, not under this module's own name", r.subjects)
|
||||
}
|
||||
a := r.published[id]
|
||||
if err := a.Check(t0); err != nil {
|
||||
if err := occ.Check(a, t0); err != nil {
|
||||
t.Fatalf("the router would refuse the ask: %v", err)
|
||||
}
|
||||
// At a glance (issue 639): what it does is the explanation, one line; the text is the Details, which the
|
||||
// router notes collapsed after the answers.
|
||||
if a.Explanation != "Every Claude session on the mesh reads these 4 lines as the mesh's own word, from its next start until they are removed the same way." ||
|
||||
asks.Glance(a.Explanation) != nil {
|
||||
occ.Glance(a.Explanation) != nil {
|
||||
t.Errorf("the phone does not say what is asked at a glance: %q", a.Explanation)
|
||||
}
|
||||
if a.Headline != "Add Claude instructions 'handoff-from-g14'?" {
|
||||
@@ -163,11 +164,11 @@ func TestAProposalAsksTheOperatorAtTheLevelApproveAndChangesNothing(t *testing.T
|
||||
t.Fatalf("options: %+v", a.Options)
|
||||
}
|
||||
for _, o := range a.Options {
|
||||
if o.Level != asks.Approve || !strings.HasPrefix(o.Binds, "sha256:") {
|
||||
if o.Level != occ.Approve || !strings.HasPrefix(o.Binds, "sha256:") {
|
||||
t.Errorf("the option %s is %s binding %q: both answers need a proven sender and bind the act", o.ID, o.Level, o.Binds)
|
||||
}
|
||||
}
|
||||
if !a.Expires.Equal(t0.Add(ProposalLasts)) {
|
||||
if a.Expires != occ.FormatTime(t0.Add(ProposalLasts)) {
|
||||
t.Errorf("expires %s", a.Expires)
|
||||
}
|
||||
// Nothing is active, here or anywhere, while the ask is open.
|
||||
@@ -212,10 +213,10 @@ func TestNothingBecomesActiveWithoutAValidWarrant(t *testing.T) {
|
||||
}{
|
||||
{"declined", func(r *router, _ *memProposals, id string) { r.close(r.chose(id, OptionDecline, t0.Add(time.Hour))) }, "declined"},
|
||||
{"expired", func(r *router, _ *memProposals, id string) {
|
||||
r.close(asks.Warrant{Ask: id, Outcome: asks.OutcomeExpired, At: t0.Add(24 * time.Hour)})
|
||||
r.close(oc.Warrant{Ask: id, Outcome: occ.OutcomeExpired, At: occ.FormatTime(t0.Add(24 * time.Hour))})
|
||||
}, "discarded"},
|
||||
{"refused by the router", func(r *router, _ *memProposals, id string) {
|
||||
r.close(asks.Warrant{Ask: id, Outcome: asks.OutcomeRefused, Words: "no channel can carry it", At: t0})
|
||||
r.close(oc.Warrant{Ask: id, Outcome: occ.OutcomeRefused, Words: "no channel can carry it", At: occ.FormatTime(t0)})
|
||||
}, "discarded"},
|
||||
{"the text changed after the ask, with its digest", func(r *router, store *memProposals, id string) {
|
||||
var prop Proposal
|
||||
@@ -243,7 +244,7 @@ func TestNothingBecomesActiveWithoutAValidWarrant(t *testing.T) {
|
||||
}, "refused"},
|
||||
{"answered at the level acknowledge", func(r *router, _ *memProposals, id string) {
|
||||
w := r.chose(id, OptionApprove, t0.Add(time.Hour))
|
||||
w.Level = asks.Acknowledge
|
||||
w.Level = occ.Acknowledge
|
||||
r.close(w)
|
||||
}, "refused"},
|
||||
{"no person chose", func(r *router, _ *memProposals, id string) {
|
||||
@@ -428,7 +429,7 @@ func TestALongTextIsShownInPartAndReadWholeAtTheTerminal(t *testing.T) {
|
||||
!strings.Contains(a.Details, "fingerprint "+out["fingerprint"].(string)) {
|
||||
t.Errorf("the phone does not say where to read all of it: %q", a.Details)
|
||||
}
|
||||
if len(a.Details) > 3000 || asks.Glance(a.Explanation) != nil {
|
||||
if len(a.Details) > 3000 || occ.Glance(a.Explanation) != nil {
|
||||
t.Errorf("the details are %d bytes; the explanation %q", len(a.Details), a.Explanation)
|
||||
}
|
||||
list, err := ListProposals(p, store, r, id, t0)
|
||||
@@ -496,7 +497,7 @@ func TestATextChangedPastWhatThePhoneShowsIsRefused(t *testing.T) {
|
||||
_ = json.Unmarshal(store.m[id], &prop)
|
||||
prop.Text = strings.Replace(prop.Text, "Ask before you push.", "Push without asking.", 1)
|
||||
_ = store.Put(id, prop)
|
||||
if prop.Ask().Digest() != r.published[id].Digest() {
|
||||
if occ.Digest(prop.Ask()) != occ.Digest(r.published[id]) {
|
||||
t.Fatal("the test means a change the ask's words do not show")
|
||||
}
|
||||
r.close(r.chose(id, OptionApprove, t0.Add(time.Hour)))
|
||||
@@ -559,7 +560,7 @@ func TestAForgedRecordIsNotAppliedEvenWhenApproved(t *testing.T) {
|
||||
} {
|
||||
prop.Digest = textDigest(prop.Text)
|
||||
_ = store.Put(prop.ID, prop)
|
||||
if err := r.Publish(asks.AskSubject(Module), prop.Ask(), prop.ID); err != nil {
|
||||
if err := r.Publish(oc.AcceptSubjectAsk(Module), prop.Ask(), prop.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r.close(r.chose(prop.ID, OptionApprove, t0.Add(time.Hour)))
|
||||
@@ -718,8 +719,8 @@ func (r *refusingRouter) Publish(subject string, body any, id string) error {
|
||||
r.id = id
|
||||
go func() {
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
_, _ = HeardEnd(r.p, r.store, asks.Warrant{Ask: id, Asker: Module, Outcome: asks.OutcomeRefused,
|
||||
Words: "no channel can carry any of its answers now: telegram is not linked", At: t0}, t0)
|
||||
_, _ = HeardEnd(r.p, r.store, oc.Warrant{Ask: id, Asker: Module, Outcome: occ.OutcomeRefused,
|
||||
Words: "no channel can carry any of its answers now: telegram is not linked", At: occ.FormatTime(t0)}, t0)
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
@@ -729,12 +730,23 @@ func TestAnAskTakenAfterItsRefusalIsStillAnswered(t *testing.T) {
|
||||
p, _ := node(t, "novox")
|
||||
store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter()
|
||||
id := proposeHandover(t, p, store, r)
|
||||
if _, err := HeardEnd(p, store, asks.Warrant{Ask: id, Asker: Module, Outcome: asks.OutcomeRefused, Words: "no channel", At: t0}, t0); err != nil {
|
||||
if _, err := HeardEnd(p, store, oc.Warrant{Ask: id, Asker: Module, Outcome: occ.OutcomeRefused, Words: "no channel", At: occ.FormatTime(t0)}, t0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := outcomeHere(t, p, id); !strings.HasPrefix(got, "discarded") {
|
||||
t.Fatalf("decided %q", got)
|
||||
}
|
||||
// A warrant whose time does not parse is refused, naming the field, as the decode of it was.
|
||||
bad := r.chose(id, OptionApprove, t0.Add(time.Hour))
|
||||
bad.At = "yesterday"
|
||||
prop, _ := readProposal(store, id)
|
||||
appr := Approved{Decided: map[string]ProposalDecision{}, Changed: map[string]time.Time{}, Refused: map[string]string{}}
|
||||
if d, _, _, _ := decide(p, &appr, prop, bad, t0.Add(time.Hour)); !strings.HasPrefix(d.Outcome, "refused: the warrant's at") {
|
||||
t.Errorf("a warrant with a malformed at was decided %q", d.Outcome)
|
||||
}
|
||||
if (routerRecord{Warrant: &bad}).check() == nil {
|
||||
t.Error("a router record with a malformed at passed its check")
|
||||
}
|
||||
r.close(r.chose(id, OptionApprove, t0.Add(time.Hour)))
|
||||
if changed, _, _ := Reconcile(p, store, r, nil, t0.Add(time.Hour)); !changed {
|
||||
t.Fatal("the approval of an ask taken after its refusal was lost")
|
||||
@@ -769,7 +781,7 @@ func TestAProposalIsAskedAtAGlanceAndAnOlderOneAsItWasAsked(t *testing.T) {
|
||||
older := prop
|
||||
older.Form = 0
|
||||
listed := older.Ask()
|
||||
if !strings.Contains(listed.Explanation, "The whole text:") || listed.Details != "" || listed.Digest() == prop.Ask().Digest() {
|
||||
if !strings.Contains(listed.Explanation, "The whole text:") || listed.Details != "" || occ.Digest(listed) == occ.Digest(prop.Ask()) {
|
||||
t.Errorf("a proposal made before is not composed as it was asked: %q", listed.Explanation)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ package main
|
||||
//
|
||||
// The session reads the answer by the question's key with claude_code_answer: from the router's own record of the
|
||||
// ask, which only the router writes and only this module reads, held to the question as the `questions` state keeps
|
||||
// it (asks.Warrant.For and asks.Option.Performs). A record that does not hold is said as not proven, never as a
|
||||
// it (oc.Warrant.For and oc.Option.Performs). A record that does not hold is said as not proven, never as a
|
||||
// choice. An answer informs the session; it authorises nothing (ADR 0286 stays the way to an approved call).
|
||||
|
||||
import (
|
||||
@@ -27,8 +27,9 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
|
||||
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
)
|
||||
|
||||
// QuestionsState is the module's state the questions are kept in.
|
||||
@@ -38,16 +39,16 @@ const QuestionsState = "questions"
|
||||
const QuestionPrefix = "q-"
|
||||
|
||||
// MostQuestions is how many questions may wait at once, mesh-wide: the router allows an asker three open asks
|
||||
// (asks.MostOpen), and questions alone never fill them, so one is left for a proposed instruction section (ADR 0274)
|
||||
// (occ.MostOpen), and questions alone never fill them, so one is left for a proposed instruction section (ADR 0274)
|
||||
// while two questions are open. Each open question holds one slot key in the `questions` state (slotKey), taken by
|
||||
// create or by compare-and-set, so two nodes asking at once cannot both take the last one.
|
||||
const MostQuestions = asks.MostOpen - 1
|
||||
const MostQuestions = occ.MostOpen - 1
|
||||
|
||||
// A question the router holds no record of, and whose refusal was not heard here, keeps its slot until it expires:
|
||||
// the router may still have it in its queue (down, or backlogged), and neither the queue's order nor a cancel can
|
||||
// take it back — the queue offers a piece of work again after an error, so a cancel may be taken before the ask it
|
||||
// cancels, and a cancel of an ask not yet recorded is dropped. Once the question has expired the router refuses it
|
||||
// when it is taken (asks.Ask.Check), so the slot is free and the question has ended.
|
||||
// when it is taken (oc.Ask.Check), so the slot is free and the question has ended.
|
||||
|
||||
// slotKey is the key of one of the MostQuestions slots: not a question's key, so no question is ever read from it.
|
||||
func slotKey(n int) string { return fmt.Sprintf("slot-%d", n) }
|
||||
@@ -69,7 +70,7 @@ const (
|
||||
// The bounds of what a question says.
|
||||
const (
|
||||
FewestQuestionOptions = 2
|
||||
MostContextBytes = asks.SummaryLength
|
||||
MostContextBytes = occ.SummaryLength
|
||||
MostMeansBytes = 200
|
||||
MostWhyBytes = 120
|
||||
MostOnExpiryBytes = 200
|
||||
@@ -151,8 +152,8 @@ func (q Question) recommended() (QuestionOption, bool) {
|
||||
}
|
||||
|
||||
// Act is what an answer binds: this question, whole, and the option chosen.
|
||||
func (q Question) Act(option string) asks.Act {
|
||||
act := asks.Act{"verb": Module + ".question", "option": option, "question": q.ID, "headline": q.Headline,
|
||||
func (q Question) Act(option string) occ.Act {
|
||||
act := occ.Act{"verb": Module + ".question", "option": option, "question": q.ID, "headline": q.Headline,
|
||||
"context": q.Context, "why": q.Why, "on-expiry": q.OnExpiry, "by": q.By, "from": q.From,
|
||||
"at": q.At.UTC().Format(time.RFC3339Nano), "expires": q.Expires.UTC().Format(time.RFC3339Nano),
|
||||
"options": strconv.Itoa(len(q.Options))}
|
||||
@@ -163,7 +164,7 @@ func (q Question) Act(option string) asks.Act {
|
||||
}
|
||||
|
||||
// Ask is the ask for a question, composed from it alone, in the form it was asked in.
|
||||
func (q Question) Ask() asks.Ask {
|
||||
func (q Question) Ask() oc.Ask {
|
||||
if q.Form == AskGlance {
|
||||
return q.glanceAsk()
|
||||
}
|
||||
@@ -172,31 +173,31 @@ func (q Question) Ask() asks.Ask {
|
||||
|
||||
// glanceAsk is the question read at a glance on the phone (novox/hq issue 639): its context, then the recommended
|
||||
// answer and why as its one point; who asked and what the answer does are its Details.
|
||||
func (q Question) glanceAsk() asks.Ask {
|
||||
func (q Question) glanceAsk() oc.Ask {
|
||||
explanation := strings.TrimSpace(q.Context)
|
||||
if rec, ok := q.recommended(); ok {
|
||||
explanation += fmt.Sprintf("\n• Recommended: %s, because %s.", rec.Label, strings.TrimRight(strings.TrimSpace(q.Why), "."))
|
||||
}
|
||||
details := fmt.Sprintf("Asked by the agent session %s on %s through the claude-code module, at %s UTC. Your answer is told "+
|
||||
"to that session; it performs nothing by itself.", q.By, q.From, q.At.UTC().Format("15:04 on 2 Jan"))
|
||||
return q.withOptions(asks.Ask{ID: q.ID, Headline: q.Headline, Explanation: explanation, Details: details})
|
||||
return q.withOptions(oc.Ask{ID: q.ID, Headline: q.Headline, Explanation: explanation, Details: details})
|
||||
}
|
||||
|
||||
func (q Question) withOptions(a asks.Ask) asks.Ask {
|
||||
a.Who, a.Expires, a.OnExpiry = asks.Operator, q.Expires, q.OnExpiry
|
||||
func (q Question) withOptions(a oc.Ask) oc.Ask {
|
||||
a.Who, a.Expires, a.OnExpiry = occ.Operator, occ.FormatTime(q.Expires), q.OnExpiry
|
||||
for _, o := range q.Options {
|
||||
does := o.Means
|
||||
if o.Recommended {
|
||||
does = "Recommended. " + does
|
||||
}
|
||||
binds, _ := asks.ActDigest(q.Act(o.ID))
|
||||
a.Options = append(a.Options, asks.Option{ID: o.ID, Label: o.Label, Does: does, Level: asks.Approve, Binds: binds})
|
||||
binds, _ := occ.ActDigest(q.Act(o.ID))
|
||||
a.Options = append(a.Options, oc.Option{ID: o.ID, Label: o.Label, Does: does, Level: occ.Approve, Binds: binds})
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// listedAsk is the ask as it was composed before issue 639: composed only to hold the answer to a question asked then.
|
||||
func (q Question) listedAsk() asks.Ask {
|
||||
func (q Question) listedAsk() oc.Ask {
|
||||
var b strings.Builder
|
||||
b.WriteString(strings.TrimSpace(q.Context))
|
||||
if rec, ok := q.recommended(); ok {
|
||||
@@ -205,15 +206,15 @@ func (q Question) listedAsk() asks.Ask {
|
||||
}
|
||||
fmt.Fprintf(&b, "\n\nAsked by the agent session %s on %s through the claude-code module, at %s UTC. "+
|
||||
"Your answer is told to that session; it performs nothing by itself.", q.By, q.From, q.At.UTC().Format("15:04 on 2 Jan"))
|
||||
a := asks.Ask{ID: q.ID, Headline: q.Headline, Explanation: b.String(), Who: asks.Operator, Expires: q.Expires,
|
||||
a := oc.Ask{ID: q.ID, Headline: q.Headline, Explanation: b.String(), Who: occ.Operator, Expires: occ.FormatTime(q.Expires),
|
||||
OnExpiry: q.OnExpiry}
|
||||
for _, o := range q.Options {
|
||||
does := o.Means
|
||||
if o.Recommended {
|
||||
does = "Recommended. " + does
|
||||
}
|
||||
binds, _ := asks.ActDigest(q.Act(o.ID))
|
||||
a.Options = append(a.Options, asks.Option{ID: o.ID, Label: o.Label, Does: does, Level: asks.Approve, Binds: binds})
|
||||
binds, _ := occ.ActDigest(q.Act(o.ID))
|
||||
a.Options = append(a.Options, oc.Option{ID: o.ID, Label: o.Label, Does: does, Level: occ.Approve, Binds: binds})
|
||||
}
|
||||
return a
|
||||
}
|
||||
@@ -228,8 +229,8 @@ func QuestionProblem(q Question) string {
|
||||
switch {
|
||||
case strings.TrimSpace(q.Headline) == "":
|
||||
add("it has no headline")
|
||||
case len([]rune(q.Headline)) > asks.HeadlineLength:
|
||||
add("its headline is %d characters, more than %d", len([]rune(q.Headline)), asks.HeadlineLength)
|
||||
case len([]rune(q.Headline)) > occ.HeadlineLength:
|
||||
add("its headline is %d characters, more than %d", len([]rune(q.Headline)), occ.HeadlineLength)
|
||||
}
|
||||
switch {
|
||||
case strings.TrimSpace(q.Context) == "":
|
||||
@@ -239,16 +240,16 @@ func QuestionProblem(q Question) string {
|
||||
case strings.Contains(strings.TrimSpace(q.Context), "\n"):
|
||||
add("its context is more than one line: a phone shows it at a glance as one short paragraph")
|
||||
}
|
||||
if n := len(q.Options); n < FewestQuestionOptions || n > asks.MostOptions {
|
||||
add("it offers %d options; a question offers %d to %d", n, FewestQuestionOptions, asks.MostOptions)
|
||||
if n := len(q.Options); n < FewestQuestionOptions || n > occ.MostOptions {
|
||||
add("it offers %d options; a question offers %d to %d", n, FewestQuestionOptions, occ.MostOptions)
|
||||
}
|
||||
recommended, labels := 0, map[string]bool{}
|
||||
for i, o := range q.Options {
|
||||
switch {
|
||||
case strings.TrimSpace(o.Label) == "":
|
||||
add("option %d has no label", i+1)
|
||||
case len([]rune(o.Label)) > asks.LabelLength:
|
||||
add("option %d's label is longer than %d characters", i+1, asks.LabelLength)
|
||||
case len([]rune(o.Label)) > occ.LabelLength:
|
||||
add("option %d's label is longer than %d characters", i+1, occ.LabelLength)
|
||||
case labels[strings.ToLower(o.Label)]:
|
||||
add("two options are labelled %q", o.Label)
|
||||
}
|
||||
@@ -258,7 +259,7 @@ func QuestionProblem(q Question) string {
|
||||
} else if len(o.Means) > MostMeansBytes {
|
||||
add("what option %d means is longer than %d bytes", i+1, MostMeansBytes)
|
||||
}
|
||||
if !asks.UsableID(o.ID) {
|
||||
if !occ.UsableID(o.ID) {
|
||||
add("option %d's id is not one", i+1)
|
||||
}
|
||||
if o.Recommended {
|
||||
@@ -368,7 +369,7 @@ func AskQuestion(p Paths, in QuestionInput, store QuestionStore, bus AskBus, now
|
||||
return refuse(problem)
|
||||
}
|
||||
ask := q.Ask()
|
||||
if err := ask.Check(now); err != nil {
|
||||
if err := occ.Check(ask, now); err != nil {
|
||||
return refuse(err.Error())
|
||||
}
|
||||
// The question is kept before its slot is taken, so a slot always names a question another node can judge.
|
||||
@@ -384,7 +385,7 @@ func AskQuestion(p Paths, in QuestionInput, store QuestionStore, bus AskBus, now
|
||||
"at once, so one of the router's three open asks is left for a proposed instruction section; read them with "+
|
||||
"claude_code_answer, or wait for one to end", len(open), strings.Join(open, ", "), MostQuestions))
|
||||
}
|
||||
if err := bus.Publish(asks.AskSubject(Module), ask, ask.ID); err != nil {
|
||||
if err := bus.Publish(oc.AcceptSubjectAsk(Module), ask, ask.ID); err != nil {
|
||||
_ = store.Put(q.ID, tombstone(q, "not asked: "+err.Error()))
|
||||
return nil, fmt.Errorf("the operator could not be asked: %v", err)
|
||||
}
|
||||
@@ -517,10 +518,10 @@ func readQuestionErr(store QuestionStore, key string) (Question, bool, error) {
|
||||
// The router's word that a question ended without a choice, as heard on the decided subject.
|
||||
var (
|
||||
questionEndsMu sync.Mutex
|
||||
questionEnds = map[string]asks.Warrant{}
|
||||
questionEnds = map[string]oc.Warrant{}
|
||||
)
|
||||
|
||||
func questionEnd(id string) (asks.Warrant, bool) {
|
||||
func questionEnd(id string) (oc.Warrant, bool) {
|
||||
questionEndsMu.Lock()
|
||||
defer questionEndsMu.Unlock()
|
||||
w, ok := questionEnds[id]
|
||||
@@ -537,8 +538,8 @@ func forgetQuestionEnd(id string) {
|
||||
// replaced — and keeps it on the question, because a refused ask leaves no record of the router's. A refused question
|
||||
// keeps none of its words (tombstone). A choice is never taken from this word: only from the router's record, held to
|
||||
// the question (ReadAnswer).
|
||||
func HeardQuestionEnd(store QuestionStore, w asks.Warrant) {
|
||||
if w.Asker != Module || w.Outcome == asks.OutcomeChosen || !strings.HasPrefix(w.Ask, QuestionPrefix) {
|
||||
func HeardQuestionEnd(store QuestionStore, w oc.Warrant) {
|
||||
if w.Asker != Module || w.Outcome == occ.OutcomeChosen || !strings.HasPrefix(w.Ask, QuestionPrefix) {
|
||||
return
|
||||
}
|
||||
q, known := readQuestion(store, w.Ask)
|
||||
@@ -550,7 +551,7 @@ func HeardQuestionEnd(store QuestionStore, w asks.Warrant) {
|
||||
if w.Words != "" {
|
||||
ended += ": " + w.Words
|
||||
}
|
||||
if w.Outcome == asks.OutcomeRefused {
|
||||
if w.Outcome == occ.OutcomeRefused {
|
||||
q = tombstone(q, ended)
|
||||
} else {
|
||||
q.Ended = ended
|
||||
@@ -589,7 +590,10 @@ func ReadAnswer(store QuestionStore, bus AskBus, key string, now time.Time) (map
|
||||
}
|
||||
var rec routerRecord
|
||||
if entry != nil {
|
||||
if err := json.Unmarshal(entry.Value, &rec); err != nil {
|
||||
if err := json.Unmarshal(entry.Value, &rec); err != nil || rec.check() != nil {
|
||||
if err == nil {
|
||||
err = rec.check()
|
||||
}
|
||||
out["state"], out["words"] = AnswerNotProven, "the router's record of it cannot be read: "+err.Error()
|
||||
return out, nil
|
||||
}
|
||||
@@ -613,12 +617,12 @@ func ReadAnswer(store QuestionStore, bus AskBus, key string, now time.Time) (map
|
||||
}
|
||||
default:
|
||||
w := *rec.Warrant
|
||||
if w.Outcome != asks.OutcomeChosen {
|
||||
if w.Outcome != occ.OutcomeChosen {
|
||||
out["state"], out["words"] = AnswerEnded, string(w.Outcome)
|
||||
if w.Words != "" {
|
||||
out["words"] = string(w.Outcome) + ": " + w.Words
|
||||
}
|
||||
if w.Outcome == asks.OutcomeExpired {
|
||||
if w.Outcome == occ.OutcomeExpired {
|
||||
out["now"] = "do what you said: " + q.OnExpiry
|
||||
}
|
||||
return out, nil
|
||||
@@ -627,9 +631,9 @@ func ReadAnswer(store QuestionStore, bus AskBus, key string, now time.Time) (map
|
||||
out["state"], out["words"] = AnswerNotProven, "the question kept is not one this module asks: "+problem
|
||||
return out, nil
|
||||
}
|
||||
option, err := w.For(Module, q.Ask())
|
||||
option, err := occ.For(w, Module, q.Ask())
|
||||
if err == nil {
|
||||
err = option.Performs(q.Act(option.ID))
|
||||
err = occ.Performs(option, q.Act(option.ID))
|
||||
}
|
||||
if err != nil {
|
||||
say("REFUSED the answer to question %s: %v", q.ID, err)
|
||||
@@ -642,8 +646,9 @@ func ReadAnswer(store QuestionStore, bus AskBus, key string, now time.Time) (map
|
||||
chosen["means"], chosen["recommended"] = o.Means, o.Recommended
|
||||
}
|
||||
}
|
||||
out["state"], out["chosen"], out["by"], out["channel"], out["at"] = AnswerChosen, chosen, w.Says(), w.Channel,
|
||||
w.At.Format(time.RFC3339)
|
||||
at, _ := warrantAt(w) // checked with the record above
|
||||
out["state"], out["chosen"], out["by"], out["channel"], out["at"] = AnswerChosen, chosen, occ.Says(w), w.Channel,
|
||||
at.Format(time.RFC3339)
|
||||
out["note"] = "the operator's word to this session; it authorises no call that needs the operator's approval"
|
||||
}
|
||||
return out, nil
|
||||
@@ -663,3 +668,37 @@ func WaitForAnswer(store QuestionStore, bus AskBus, key string, wait time.Durati
|
||||
time.Sleep(AnswerLooksEvery)
|
||||
}
|
||||
}
|
||||
|
||||
// warrantAt is when a warrant was made; the contract carries it as an RFC 3339 string, and one that does not parse is
|
||||
// refused, as the decode of the router's word was before the contract carried it as a string.
|
||||
func warrantAt(w oc.Warrant) (time.Time, error) {
|
||||
t, err := occ.Time(w.At)
|
||||
if err != nil {
|
||||
return time.Time{}, fmt.Errorf("the warrant's at %q is not a time: %w", w.At, err)
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// askExpires is when an ask ends, read from the contract's RFC 3339 string; one that does not parse is refused.
|
||||
func askExpires(a oc.Ask) (time.Time, error) {
|
||||
t, err := occ.Time(a.Expires)
|
||||
if err != nil {
|
||||
return time.Time{}, fmt.Errorf("the ask's expires %q is not a time: %w", a.Expires, err)
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// check refuses a router record whose warrant or ask carries a time that does not parse.
|
||||
func (r routerRecord) check() error {
|
||||
if r.Warrant != nil {
|
||||
if _, err := warrantAt(*r.Warrant); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if r.Ask != nil {
|
||||
if _, err := askExpires(*r.Ask); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -12,8 +12,9 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
|
||||
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
)
|
||||
|
||||
func mergeTonight() QuestionInput {
|
||||
@@ -50,18 +51,18 @@ func TestAQuestionIsAskedUnderTheModulesNameAtTheLevelApprove(t *testing.T) {
|
||||
if !strings.HasPrefix(key, QuestionPrefix) || !strings.Contains(out["router"].(string), "took the question") {
|
||||
t.Fatalf("answered %v", out)
|
||||
}
|
||||
if len(r.subjects) != 1 || r.subjects[0] != asks.AskSubject(Module) {
|
||||
if len(r.subjects) != 1 || r.subjects[0] != oc.AcceptSubjectAsk(Module) {
|
||||
t.Fatalf("published on %v", r.subjects)
|
||||
}
|
||||
a := r.published[key]
|
||||
if a.Who != asks.Operator || a.OnExpiry != "I leave the pull request open" || !a.Expires.Equal(t0.Add(QuestionDefault)) {
|
||||
if a.Who != occ.Operator || a.OnExpiry != "I leave the pull request open" || a.Expires != occ.FormatTime(t0.Add(QuestionDefault)) {
|
||||
t.Errorf("ask %+v", a)
|
||||
}
|
||||
if len(a.Options) != 2 {
|
||||
t.Fatalf("options %+v", a.Options)
|
||||
}
|
||||
for _, o := range a.Options {
|
||||
if o.Level != asks.Approve || o.Binds == "" {
|
||||
if o.Level != occ.Approve || o.Binds == "" {
|
||||
t.Errorf("option %s is %s binding %q: every option is an approval bound to the question", o.ID, o.Level, o.Binds)
|
||||
}
|
||||
}
|
||||
@@ -69,7 +70,7 @@ func TestAQuestionIsAskedUnderTheModulesNameAtTheLevelApprove(t *testing.T) {
|
||||
t.Errorf("the recommendation is not on the recommended option alone: %+v", a.Options)
|
||||
}
|
||||
// At a glance (issue 639): the context and the recommendation are the explanation; who asks is the Details.
|
||||
if !strings.HasSuffix(a.Explanation, "\n• Recommended: Merge now, because it is reviewed and the checks are green.") || asks.Glance(a.Explanation) != nil {
|
||||
if !strings.HasSuffix(a.Explanation, "\n• Recommended: Merge now, because it is reviewed and the checks are green.") || occ.Glance(a.Explanation) != nil {
|
||||
t.Errorf("the explanation does not read at a glance:\n%s", a.Explanation)
|
||||
}
|
||||
for _, want := range []string{"the agent session fix-589 on g14", "performs nothing by itself"} {
|
||||
@@ -77,7 +78,7 @@ func TestAQuestionIsAskedUnderTheModulesNameAtTheLevelApprove(t *testing.T) {
|
||||
t.Errorf("the details do not say %q:\n%s", want, a.Details)
|
||||
}
|
||||
}
|
||||
if err := a.Check(t0); err != nil {
|
||||
if err := occ.Check(a, t0); err != nil {
|
||||
t.Errorf("the ask does not pass the asks contract: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -212,8 +213,8 @@ func (r *questionRefusingRouter) Publish(subject string, body any, id string) er
|
||||
r.id = id
|
||||
go func() {
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
HeardQuestionEnd(r.store, asks.Warrant{Ask: id, Asker: Module, Outcome: asks.OutcomeRefused,
|
||||
Words: "its words carry path (a file path), which may not leave the mesh", At: t0})
|
||||
HeardQuestionEnd(r.store, oc.Warrant{Ask: id, Asker: Module, Outcome: occ.OutcomeRefused,
|
||||
Words: "its words carry path (a file path), which may not leave the mesh", At: occ.FormatTime(t0)})
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
@@ -247,7 +248,7 @@ func TestAnExpiredQuestionSaysWhatTheSessionDoes(t *testing.T) {
|
||||
p, _ := node(t, "g14")
|
||||
store, r := newMemQuestions(), newRouter()
|
||||
key := askMergeTonight(t, p, store, r, t0)
|
||||
r.close(asks.Warrant{Ask: key, Outcome: asks.OutcomeExpired, At: t0.Add(QuestionDefault)})
|
||||
r.close(oc.Warrant{Ask: key, Outcome: occ.OutcomeExpired, At: occ.FormatTime(t0.Add(QuestionDefault))})
|
||||
out, err := ReadAnswer(store, r, key, t0.Add(QuestionDefault+time.Minute))
|
||||
if err != nil || out["state"] != AnswerEnded || out["now"] != "do what you said: I leave the pull request open" {
|
||||
t.Errorf("expired: %v %v", out, err)
|
||||
@@ -276,7 +277,7 @@ func TestTheQuestionToolsAreServed(t *testing.T) {
|
||||
var asked map[string]any
|
||||
_ = json.Unmarshal([]byte(text), &asked)
|
||||
key, _ := asked["key"].(string)
|
||||
if a := r.published[key]; !a.Expires.Equal(t0.Add(30 * time.Minute)) {
|
||||
if a := r.published[key]; a.Expires != occ.FormatTime(t0.Add(30*time.Minute)) {
|
||||
t.Errorf("expires %v", a.Expires)
|
||||
}
|
||||
r.close(r.chose(key, "o1", t0.Add(time.Minute)))
|
||||
@@ -381,10 +382,10 @@ func TestTheEdgesOfAQuestionAreAsked(t *testing.T) {
|
||||
// A warrant naming another asker or another ask, or an option the question did not offer, is no answer to it.
|
||||
func TestAWarrantForAnotherAskOrOptionIsNotProven(t *testing.T) {
|
||||
p, _ := node(t, "g14")
|
||||
for name, change := range map[string]func(*asks.Warrant){
|
||||
"another asker": func(w *asks.Warrant) { w.Asker = "mesh-controller" },
|
||||
"another ask": func(w *asks.Warrant) { w.Ask = "q-0000000000000000" },
|
||||
"an option unoffered": func(w *asks.Warrant) { w.Option, w.Label = "o9", "Delete everything" },
|
||||
for name, change := range map[string]func(*oc.Warrant){
|
||||
"another asker": func(w *oc.Warrant) { w.Asker = "mesh-controller" },
|
||||
"another ask": func(w *oc.Warrant) { w.Ask = "q-0000000000000000" },
|
||||
"an option unoffered": func(w *oc.Warrant) { w.Option, w.Label = "o9", "Delete everything" },
|
||||
} {
|
||||
store, r := newMemQuestions(), newRouter()
|
||||
key := askMergeTonight(t, p, store, r, t0)
|
||||
@@ -474,7 +475,7 @@ func TestASlotIsTakenOnlyAtTheRevisionRead(t *testing.T) {
|
||||
store, r := newMemQuestions(), newRouter()
|
||||
// Two questions that ended: their slots are free to take again.
|
||||
for _, id := range []string{askMergeTonight(t, p, store, r, t0), askMergeTonight(t, p, store, r, t0)} {
|
||||
r.close(asks.Warrant{Ask: id, Outcome: asks.OutcomeExpired, At: t0.Add(time.Minute)})
|
||||
r.close(oc.Warrant{Ask: id, Outcome: occ.OutcomeExpired, At: occ.FormatTime(t0.Add(time.Minute))})
|
||||
}
|
||||
shanks, _ := node(t, "shanks")
|
||||
other := newRouter()
|
||||
@@ -487,7 +488,7 @@ func TestASlotIsTakenOnlyAtTheRevisionRead(t *testing.T) {
|
||||
From: shanks.Node, At: t0, Expires: t0.Add(time.Hour),
|
||||
Options: []QuestionOption{{ID: "o1", Label: "A", Means: "a", Recommended: true}, {ID: "o2", Label: "B", Means: "b"}}}
|
||||
_ = store.Put(q.ID, q)
|
||||
_ = other.Publish(asks.AskSubject(Module), q.Ask(), q.ID)
|
||||
_ = other.Publish(oc.AcceptSubjectAsk(Module), q.Ask(), q.ID)
|
||||
r.records[q.ID] = other.records[q.ID]
|
||||
_ = store.Put(key, slot{Question: q.ID, At: t0})
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
"git.novox.be/novox/mesh-sdk/go/channel"
|
||||
)
|
||||
|
||||
// Kind is this channel's kind on both benches.
|
||||
@@ -59,19 +59,19 @@ const StandingEvery = 5 * time.Minute
|
||||
|
||||
// Standing is what the desk says of itself: the bench's word, and when a named machine's notifier last lost
|
||||
// the answers its notifications waited for (its bundle started again). The router then says again what is
|
||||
// still open and unanswered (issue 384). It belongs in asks.Standing once the SDK carries it; until then the
|
||||
// still open and unanswered (issue 384). It belongs in channel.Standing once the SDK carries it; until then the
|
||||
// field travels as JSON the router reads under the same name. Zero (as "0001-01-01T00:00:00Z") until then.
|
||||
type Standing struct {
|
||||
asks.Standing
|
||||
channel.Standing
|
||||
AnswersLostAt time.Time `json:"answers-lost-at"`
|
||||
}
|
||||
|
||||
// Shown is one of the router's messages as the desk shows it: the message, and its notification on each
|
||||
// machine.
|
||||
type Shown struct {
|
||||
Message asks.Message `json:"message"`
|
||||
On map[string]int `json:"on"`
|
||||
Sent time.Time `json:"sent"`
|
||||
Message channel.Message `json:"message"`
|
||||
On map[string]int `json:"on"`
|
||||
Sent time.Time `json:"sent"`
|
||||
}
|
||||
|
||||
// Store keeps what is shown, by the router's handle.
|
||||
@@ -146,7 +146,7 @@ func (d *Desk) Take(subject string, body json.RawMessage) error {
|
||||
d.Logf("[desk-channel] work on %s is not this kind's; taken and dropped", subject)
|
||||
return nil
|
||||
}
|
||||
var m asks.Message
|
||||
var m channel.Message
|
||||
if err := json.Unmarshal(body, &m); err != nil || m.Handle == "" {
|
||||
d.Logf("[desk-channel] work on %s could not be read; taken and dropped", subject)
|
||||
return nil
|
||||
@@ -158,8 +158,8 @@ func (d *Desk) Take(subject string, body json.RawMessage) error {
|
||||
// would appear later, to whoever is at the screen then.
|
||||
seeing, why := d.seeing()
|
||||
if len(seeing) == 0 {
|
||||
f := asks.Failed{Handle: m.Handle, Why: "not shown: " + why, At: d.Now()}
|
||||
return d.Publish(asks.IntakeSubject(asks.FailedWhat, Kind), f, "failed."+m.Handle+"."+strconv.FormatInt(d.Now().UnixNano(), 10))
|
||||
f := channel.Failed{Handle: m.Handle, Why: "not shown: " + why, At: d.Now()}
|
||||
return d.Publish(channel.IntakeSubject(channel.FailedWhat, Kind), f, "failed."+m.Handle+"."+strconv.FormatInt(d.Now().UnixNano(), 10))
|
||||
}
|
||||
machines = seeing
|
||||
}
|
||||
@@ -176,7 +176,7 @@ func (d *Desk) Take(subject string, body json.RawMessage) error {
|
||||
// shown (a reminder, an escalation), replace the notification it had on each machine — dunst's replace id —
|
||||
// so a problem is one notification updated in place, never one more beside it. An edit stays quiet where
|
||||
// it was shown; a message said again is shown anew where it was not.
|
||||
edit := parts[4] == asks.Edit
|
||||
edit := parts[4] == channel.Edit
|
||||
var before *Shown
|
||||
switch {
|
||||
case edit:
|
||||
@@ -226,8 +226,8 @@ func (d *Desk) Take(subject string, body json.RawMessage) error {
|
||||
if len(machines) == 0 {
|
||||
why = "no machine to show it on: the setting desktop-machines names none"
|
||||
}
|
||||
f := asks.Failed{Handle: m.Handle, Why: why, At: d.Now()}
|
||||
if err := d.Publish(asks.IntakeSubject(asks.FailedWhat, Kind), f, "failed."+m.Handle+"."+strconv.FormatInt(d.Now().UnixNano(), 10)); err != nil {
|
||||
f := channel.Failed{Handle: m.Handle, Why: why, At: d.Now()}
|
||||
if err := d.Publish(channel.IntakeSubject(channel.FailedWhat, Kind), f, "failed."+m.Handle+"."+strconv.FormatInt(d.Now().UnixNano(), 10)); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
@@ -322,7 +322,7 @@ func sameShown(a, b Shown) bool {
|
||||
}
|
||||
|
||||
// show puts one message on one machine's notifier, in place of `replace` when it names one.
|
||||
func (d *Desk) show(machine string, m asks.Message, replace int) (int, error) {
|
||||
func (d *Desk) show(machine string, m channel.Message, replace int) (int, error) {
|
||||
urgency := "normal"
|
||||
switch {
|
||||
case m.Urgent:
|
||||
@@ -376,12 +376,12 @@ func (d *Desk) Chosen(event, machine, ticket string) {
|
||||
}
|
||||
handle, shown := d.find(ticket)
|
||||
if shown == nil {
|
||||
_, _ = d.show(machine, asks.Message{Title: "That answer was not taken",
|
||||
_, _ = d.show(machine, channel.Message{Title: "That answer was not taken",
|
||||
Body: "It belongs to no message shown here any more. Nothing was done.", Silent: true}, 0)
|
||||
return
|
||||
}
|
||||
if _, here := shown.On[machine]; !here {
|
||||
_, _ = d.show(machine, asks.Message{Title: "That answer was not taken",
|
||||
_, _ = d.show(machine, channel.Message{Title: "That answer was not taken",
|
||||
Body: "It was not shown on " + machine + ". Nothing was done.", Silent: true}, 0)
|
||||
return
|
||||
}
|
||||
@@ -395,11 +395,11 @@ func (d *Desk) Chosen(event, machine, ticket string) {
|
||||
}
|
||||
d.clickedOn[id] = machine
|
||||
d.mu.Unlock()
|
||||
c := asks.Choice{ID: id, Ticket: ticket, Handle: handle, At: d.Now(),
|
||||
Sender: asks.Sender{Identity: machine, Display: machine, Authenticated: false}}
|
||||
if err := d.Publish(asks.IntakeSubject(asks.Chosen, Kind), c, id); err != nil {
|
||||
c := channel.Choice{ID: id, Ticket: ticket, Handle: handle, At: d.Now(),
|
||||
Sender: channel.Sender{Identity: machine, Display: machine, Authenticated: false}}
|
||||
if err := d.Publish(channel.IntakeSubject(channel.Chosen, Kind), c, id); err != nil {
|
||||
d.Logf("[desk-channel] a choice could not be said to the router: %v", err)
|
||||
_, _ = d.show(machine, asks.Message{Title: "That answer could not be taken now",
|
||||
_, _ = d.show(machine, channel.Message{Title: "That answer could not be taken now",
|
||||
Body: "The mesh did not take it. Try again in a minute.", Silent: true}, 0)
|
||||
}
|
||||
}
|
||||
@@ -508,7 +508,7 @@ func (d *Desk) seeing() ([]string, string) {
|
||||
// (the review of 2026-10-09). The router shows no link's code while the desk is not ready.
|
||||
func (d *Desk) Standing() Standing {
|
||||
d.init()
|
||||
s := Standing{Standing: asks.Standing{At: d.Now()}, AnswersLostAt: d.answersLostAt()}
|
||||
s := Standing{Standing: channel.Standing{At: d.Now()}, AnswersLostAt: d.answersLostAt()}
|
||||
if _, why := d.seeing(); why != "" {
|
||||
s.Why = why
|
||||
return s
|
||||
@@ -542,7 +542,7 @@ func (d *Desk) SayStanding() {
|
||||
if same {
|
||||
return
|
||||
}
|
||||
if err := d.Publish(asks.IntakeSubject(asks.StandingWhat, Kind), s, "standing."+strconv.FormatInt(s.At.UnixNano(), 10)); err != nil {
|
||||
if err := d.Publish(channel.IntakeSubject(channel.StandingWhat, Kind), s, "standing."+strconv.FormatInt(s.At.UnixNano(), 10)); err != nil {
|
||||
d.Logf("[desk-channel] could not say whether it can send: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
"git.novox.be/novox/mesh-sdk/go/channel"
|
||||
)
|
||||
|
||||
type memShown map[string]Shown
|
||||
@@ -62,10 +62,10 @@ func newDesk(t *testing.T, machines ...string) (*Desk, *[]notified, map[string][
|
||||
return d, &shown, said, store
|
||||
}
|
||||
|
||||
func take(t *testing.T, d *Desk, verb string, m asks.Message) {
|
||||
func take(t *testing.T, d *Desk, verb string, m channel.Message) {
|
||||
t.Helper()
|
||||
raw, _ := json.Marshal(m)
|
||||
if err := d.Take(asks.ChannelSubject(verb, Kind), raw); err != nil {
|
||||
if err := d.Take(channel.ChannelSubject(verb, Kind), raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -74,16 +74,16 @@ func take(t *testing.T, d *Desk, verb string, m asks.Message) {
|
||||
// says a click as a choice the router judges — only on a machine the message was shown on.
|
||||
func TestAClickOnAMachineTheMessageWasShownOnIsSaidToTheRouter(t *testing.T) {
|
||||
d, shown, said, _ := newDesk(t, "laptop", "away-machine")
|
||||
take(t, d, asks.Show, asks.Message{Handle: "m1", Title: "3 failed services", Body: "Needs you: <mend> them.",
|
||||
Buttons: []asks.Button{{Label: "Silence for a week", Ticket: "t1"}}})
|
||||
take(t, d, channel.Show, channel.Message{Handle: "m1", Title: "3 failed services", Body: "Needs you: <mend> them.",
|
||||
Buttons: []channel.Button{{Label: "Silence for a week", Ticket: "t1"}}})
|
||||
if len(*shown) != 1 || (*shown)[0].machine != "laptop" || (*shown)[0].args["expire_ms"] != 0 ||
|
||||
(*shown)[0].args["body"] != "Needs you: <mend> them." {
|
||||
t.Fatalf("shown %+v", *shown)
|
||||
}
|
||||
d.Chosen("e1", "laptop", "t1")
|
||||
d.Chosen("e1", "laptop", "t1") // a redelivery
|
||||
got := said[asks.IntakeSubject(asks.Chosen, Kind)]
|
||||
var c asks.Choice
|
||||
got := said[channel.IntakeSubject(channel.Chosen, Kind)]
|
||||
var c channel.Choice
|
||||
if len(got) != 1 || json.Unmarshal(got[0], &c) != nil || c.Ticket != "t1" || c.Handle != "m1" ||
|
||||
c.Sender.Identity != "laptop" || c.Sender.Authenticated {
|
||||
t.Fatalf("said %s", got)
|
||||
@@ -91,14 +91,14 @@ func TestAClickOnAMachineTheMessageWasShownOnIsSaidToTheRouter(t *testing.T) {
|
||||
// Elsewhere it is refused, there, and nothing is said to the router.
|
||||
d.Chosen("e2", "workstation", "t1")
|
||||
d.Chosen("e3", "laptop", "forged")
|
||||
if len(said[asks.IntakeSubject(asks.Chosen, Kind)]) != 1 {
|
||||
if len(said[channel.IntakeSubject(channel.Chosen, Kind)]) != 1 {
|
||||
t.Error("a click not shown there was said")
|
||||
}
|
||||
if last := (*shown)[len(*shown)-1]; last.machine != "laptop" || last.args["summary"] != "That answer was not taken" {
|
||||
t.Errorf("the refusal was not shown where it was chosen: %+v", last)
|
||||
}
|
||||
// The router's reply to the click is shown on the machine it was made on.
|
||||
take(t, d, asks.Send, asks.Message{Handle: "r1", Title: "Details: 3 failed services", Reply: c.ID})
|
||||
take(t, d, channel.Send, channel.Message{Handle: "r1", Title: "Details: 3 failed services", Reply: c.ID})
|
||||
if last := (*shown)[len(*shown)-1]; last.machine != "laptop" || last.args["summary"] != "Details: 3 failed services" {
|
||||
t.Errorf("%+v", last)
|
||||
}
|
||||
@@ -107,8 +107,8 @@ func TestAClickOnAMachineTheMessageWasShownOnIsSaidToTheRouter(t *testing.T) {
|
||||
// The note follows the body after a blank line: the desk has no smaller text, and leaves nothing out (issue 639).
|
||||
func TestTheNoteFollowsTheBody(t *testing.T) {
|
||||
d, shown, _, _ := newDesk(t, "laptop")
|
||||
take(t, d, asks.Show, asks.Message{Handle: "m1", Title: "q", Body: "The summary.", Note: "Asked by mesh-knowledge at 16:05.",
|
||||
Buttons: []asks.Button{{Label: "Silence for a week", Ticket: "t1"}}})
|
||||
take(t, d, channel.Show, channel.Message{Handle: "m1", Title: "q", Body: "The summary.", Note: "Asked by mesh-knowledge at 16:05.",
|
||||
Buttons: []channel.Button{{Label: "Silence for a week", Ticket: "t1"}}})
|
||||
if last := (*shown)[len(*shown)-1]; last.args["body"] != "The summary.\n\nAsked by mesh-knowledge at 16:05." {
|
||||
t.Errorf("the body: %q", last.args["body"])
|
||||
}
|
||||
@@ -116,8 +116,8 @@ func TestTheNoteFollowsTheBody(t *testing.T) {
|
||||
|
||||
func TestAnEditReplacesTheNotification(t *testing.T) {
|
||||
d, shown, _, _ := newDesk(t, "laptop")
|
||||
take(t, d, asks.Show, asks.Message{Handle: "m1", Title: "q", Buttons: []asks.Button{{Label: "Silence for a week", Ticket: "t1"}}})
|
||||
take(t, d, asks.Edit, asks.Message{Handle: "m1", Title: "Silence for a week: chosen"})
|
||||
take(t, d, channel.Show, channel.Message{Handle: "m1", Title: "q", Buttons: []channel.Button{{Label: "Silence for a week", Ticket: "t1"}}})
|
||||
take(t, d, channel.Edit, channel.Message{Handle: "m1", Title: "Silence for a week: chosen"})
|
||||
if last := (*shown)[len(*shown)-1]; last.args["replace_id"] != 1 || last.args["actions"] != nil {
|
||||
t.Errorf("the edit: %+v", last.args)
|
||||
}
|
||||
@@ -134,17 +134,17 @@ func TestANotifierThatStartedAgainIsShownNothingFromMemory(t *testing.T) {
|
||||
d, shown, said, store := newDesk(t, "laptop")
|
||||
d.Paused("laptop", false)
|
||||
d.SayStanding()
|
||||
take(t, d, asks.Show, asks.Message{Handle: "open", Title: "2 failed services on shanks",
|
||||
Buttons: []asks.Button{{Label: "Silence for a week", Ticket: "t-open"}}})
|
||||
take(t, d, asks.Show, asks.Message{Handle: "silenced", Title: "Agent-home shrank",
|
||||
Buttons: []asks.Button{{Label: "Silence for a week", Ticket: "t-silenced"}}})
|
||||
take(t, d, asks.Edit, asks.Message{Handle: "silenced", Title: "Silenced until Saturday: Agent-home shrank", Silent: true})
|
||||
take(t, d, asks.Show, asks.Message{Handle: "resolved", Title: "Questions for you not delivered",
|
||||
Buttons: []asks.Button{{Label: "Details", Ticket: "t-resolved"}}})
|
||||
take(t, d, asks.Edit, asks.Message{Handle: "resolved", Title: "Resolved: questions for you are delivered", Silent: true})
|
||||
take(t, d, channel.Show, channel.Message{Handle: "open", Title: "2 failed services on shanks",
|
||||
Buttons: []channel.Button{{Label: "Silence for a week", Ticket: "t-open"}}})
|
||||
take(t, d, channel.Show, channel.Message{Handle: "silenced", Title: "Agent-home shrank",
|
||||
Buttons: []channel.Button{{Label: "Silence for a week", Ticket: "t-silenced"}}})
|
||||
take(t, d, channel.Edit, channel.Message{Handle: "silenced", Title: "Silenced until Saturday: Agent-home shrank", Silent: true})
|
||||
take(t, d, channel.Show, channel.Message{Handle: "resolved", Title: "Questions for you not delivered",
|
||||
Buttons: []channel.Button{{Label: "Details", Ticket: "t-resolved"}}})
|
||||
take(t, d, channel.Edit, channel.Message{Handle: "resolved", Title: "Resolved: questions for you are delivered", Silent: true})
|
||||
openID := store["open"].On["laptop"]
|
||||
before := len(*shown)
|
||||
standings := len(said[asks.IntakeSubject(asks.StandingWhat, Kind)])
|
||||
standings := len(said[channel.IntakeSubject(channel.StandingWhat, Kind)])
|
||||
later := d.Now().Add(time.Hour)
|
||||
d.Now = func() time.Time { return later }
|
||||
d.Paused("laptop", false)
|
||||
@@ -157,25 +157,25 @@ func TestANotifierThatStartedAgainIsShownNothingFromMemory(t *testing.T) {
|
||||
if store["open"].On["laptop"] != openID {
|
||||
t.Fatalf("the open message's notification was forgotten: %+v", store["open"].On)
|
||||
}
|
||||
got := said[asks.IntakeSubject(asks.StandingWhat, Kind)]
|
||||
got := said[channel.IntakeSubject(channel.StandingWhat, Kind)]
|
||||
var s Standing
|
||||
if len(got) != standings+1 || json.Unmarshal(got[len(got)-1], &s) != nil || !s.Ready || !s.AnswersLostAt.Equal(later) {
|
||||
t.Fatalf("the standing after the start: %d said, last %s", len(got)-standings, got[len(got)-1])
|
||||
}
|
||||
// The router says the open one again under its handle: in place of its notification, with its answers.
|
||||
take(t, d, asks.Show, asks.Message{Handle: "open", Title: "2 failed services on shanks",
|
||||
Buttons: []asks.Button{{Label: "Silence for a week", Ticket: "t-open"}}})
|
||||
take(t, d, channel.Show, channel.Message{Handle: "open", Title: "2 failed services on shanks",
|
||||
Buttons: []channel.Button{{Label: "Silence for a week", Ticket: "t-open"}}})
|
||||
last := (*shown)[len(*shown)-1]
|
||||
if len(*shown) != before+1 || last.args["replace_id"] != openID || last.args["actions"] == nil {
|
||||
t.Fatalf("the open message said again: %+v", last.args)
|
||||
}
|
||||
d.Chosen("e2", "laptop", "t-open")
|
||||
if len(said[asks.IntakeSubject(asks.Chosen, Kind)]) != 1 {
|
||||
if len(said[channel.IntakeSubject(channel.Chosen, Kind)]) != 1 {
|
||||
t.Error("a click on the message said again was not said to the router")
|
||||
}
|
||||
// The same start event again (a redelivery) does nothing more.
|
||||
standings = len(said[asks.IntakeSubject(asks.StandingWhat, Kind)])
|
||||
if n := d.Started("e-start", "laptop"); n != 0 || len(said[asks.IntakeSubject(asks.StandingWhat, Kind)]) != standings {
|
||||
standings = len(said[channel.IntakeSubject(channel.StandingWhat, Kind)])
|
||||
if n := d.Started("e-start", "laptop"); n != 0 || len(said[channel.IntakeSubject(channel.StandingWhat, Kind)]) != standings {
|
||||
t.Errorf("a redelivered start was acted on again: %d", n)
|
||||
}
|
||||
// A desk none of whose notifiers started again says a zero time, which the router reads as none.
|
||||
@@ -187,8 +187,8 @@ func TestANotifierThatStartedAgainIsShownNothingFromMemory(t *testing.T) {
|
||||
|
||||
func TestNoMachineThatShowsItIsAFailureSaidToTheRouter(t *testing.T) {
|
||||
d, _, said, _ := newDesk(t, "away-machine")
|
||||
take(t, d, asks.Send, asks.Message{Handle: "m1", Title: "x"})
|
||||
if f := said[asks.IntakeSubject(asks.FailedWhat, Kind)]; len(f) != 1 || !strings.Contains(string(f[0]), "no session answers") ||
|
||||
take(t, d, channel.Send, channel.Message{Handle: "m1", Title: "x"})
|
||||
if f := said[channel.IntakeSubject(channel.FailedWhat, Kind)]; len(f) != 1 || !strings.Contains(string(f[0]), "no session answers") ||
|
||||
strings.Contains(string(f[0]), `"permanent":true`) {
|
||||
t.Errorf("%s", f)
|
||||
}
|
||||
@@ -229,7 +229,7 @@ func TestItPromisesNoVerifiedSender(t *testing.T) {
|
||||
func TestASecretMessageIsShownAndNotKept(t *testing.T) {
|
||||
d, shown, _, store := newDesk(t, "laptop")
|
||||
d.Paused("laptop", false)
|
||||
take(t, d, asks.Send, asks.Message{Handle: "code", Title: "Link a Telegram account as you", Body: "123 456", Secret: true})
|
||||
take(t, d, channel.Send, channel.Message{Handle: "code", Title: "Link a Telegram account as you", Body: "123 456", Secret: true})
|
||||
if len(*shown) != 1 {
|
||||
t.Fatal("not shown")
|
||||
}
|
||||
@@ -252,8 +252,8 @@ func TestASecretIsShownOnlyWhereTheNotifierIsHeardUnpausedAndNeverKept(t *testin
|
||||
if s := d.Standing(); !s.Ready {
|
||||
t.Fatalf("not ready with laptop heard unpaused: %+v", s)
|
||||
}
|
||||
code := asks.Message{Handle: "code", Title: "Link a Telegram account as you", Body: "123 456", Secret: true}
|
||||
take(t, d, asks.Send, code)
|
||||
code := channel.Message{Handle: "code", Title: "Link a Telegram account as you", Body: "123 456", Secret: true}
|
||||
take(t, d, channel.Send, code)
|
||||
if len(*shown) != 1 || (*shown)[0].machine != "laptop" || (*shown)[0].args["secret"] != true {
|
||||
t.Fatalf("the code was shown as %+v", *shown)
|
||||
}
|
||||
@@ -262,11 +262,11 @@ func TestASecretIsShownOnlyWhereTheNotifierIsHeardUnpausedAndNeverKept(t *testin
|
||||
}
|
||||
// Paused: not shown anywhere, said as failed, and the desk is not ready.
|
||||
d.Paused("laptop", true)
|
||||
take(t, d, asks.Send, code)
|
||||
take(t, d, channel.Send, code)
|
||||
if len(*shown) != 1 {
|
||||
t.Fatalf("the code was shown on a paused notifier: %+v", *shown)
|
||||
}
|
||||
if f := said[asks.IntakeSubject(asks.FailedWhat, Kind)]; len(f) != 1 || !strings.Contains(string(f[0]), "paused") {
|
||||
if f := said[channel.IntakeSubject(channel.FailedWhat, Kind)]; len(f) != 1 || !strings.Contains(string(f[0]), "paused") {
|
||||
t.Errorf("not said as failed: %s", f)
|
||||
}
|
||||
if s := d.Standing(); s.Ready || !strings.Contains(s.Why, "paused") {
|
||||
@@ -284,7 +284,7 @@ func TestASecretIsShownOnlyWhereTheNotifierIsHeardUnpausedAndNeverKept(t *testin
|
||||
t.Error("a machine not named made the desk ready")
|
||||
}
|
||||
// A message that is not secret still goes to every named machine, paused or not.
|
||||
take(t, d, asks.Send, asks.Message{Handle: "m9", Title: "x"})
|
||||
take(t, d, channel.Send, channel.Message{Handle: "m9", Title: "x"})
|
||||
if len(*shown) != 3 {
|
||||
t.Errorf("an ordinary message was held back: %+v", *shown)
|
||||
}
|
||||
@@ -330,8 +330,8 @@ func TestItRunsAsItsOwnAccount(t *testing.T) {
|
||||
// was not shown; a message under a new handle replaces nothing.
|
||||
func TestAMessageSaidAgainUnderItsHandleReplacesItsNotification(t *testing.T) {
|
||||
d, shown, _, store := newDesk(t, "laptop", "desktop")
|
||||
take(t, d, asks.Send, asks.Message{Handle: "m1", Title: "2 failed services on shanks"})
|
||||
take(t, d, asks.Send, asks.Message{Handle: "m2", Title: "another problem"})
|
||||
take(t, d, channel.Send, channel.Message{Handle: "m1", Title: "2 failed services on shanks"})
|
||||
take(t, d, channel.Send, channel.Message{Handle: "m2", Title: "another problem"})
|
||||
if len(*shown) != 4 {
|
||||
t.Fatalf("shown %d", len(*shown))
|
||||
}
|
||||
@@ -343,8 +343,8 @@ func TestAMessageSaidAgainUnderItsHandleReplacesItsNotification(t *testing.T) {
|
||||
first := store["m1"].On
|
||||
// The desk now names a third machine as well.
|
||||
d.Machines = func() []string { return []string{"laptop", "desktop", "third"} }
|
||||
take(t, d, asks.Show, asks.Message{Handle: "m1", Title: "Still open: 2 failed services on shanks", Urgent: true,
|
||||
Buttons: []asks.Button{{Label: "Silence for a week", Ticket: "t1"}}})
|
||||
take(t, d, channel.Show, channel.Message{Handle: "m1", Title: "Still open: 2 failed services on shanks", Urgent: true,
|
||||
Buttons: []channel.Button{{Label: "Silence for a week", Ticket: "t1"}}})
|
||||
again := (*shown)[4:]
|
||||
if len(again) != 3 {
|
||||
t.Fatalf("said again on %d machines", len(again))
|
||||
@@ -397,18 +397,18 @@ func TestAFailedReplaceKeepsTheNotificationItWasToReplace(t *testing.T) {
|
||||
d, shown, _, store := newDesk(t, "laptop")
|
||||
down := true
|
||||
d.Store = downStore{store, &down}
|
||||
raised, _ := json.Marshal(asks.Message{Handle: "m9", Title: "A new build cannot be delivered", Urgent: true})
|
||||
if err := d.Take(asks.ChannelSubject(asks.Send, Kind), raised); err != nil {
|
||||
raised, _ := json.Marshal(channel.Message{Handle: "m9", Title: "A new build cannot be delivered", Urgent: true})
|
||||
if err := d.Take(channel.ChannelSubject(channel.Send, Kind), raised); err != nil {
|
||||
t.Fatalf("the raise was not taken: %v", err)
|
||||
}
|
||||
cleared, _ := json.Marshal(asks.Message{Handle: "m9", Title: "Cleared: a new build can be delivered"})
|
||||
if err := d.Take(asks.ChannelSubject(asks.Edit, Kind), cleared); err != nil {
|
||||
cleared, _ := json.Marshal(channel.Message{Handle: "m9", Title: "Cleared: a new build can be delivered"})
|
||||
if err := d.Take(channel.ChannelSubject(channel.Edit, Kind), cleared); err != nil {
|
||||
t.Fatalf("the clearing was not taken: %v", err)
|
||||
}
|
||||
d.KeepUnkept() // the minute's standing, store still down
|
||||
down = !back
|
||||
d.KeepUnkept()
|
||||
take(t, d, asks.Send, asks.Message{Handle: "m10", Title: "another"})
|
||||
take(t, d, channel.Send, channel.Message{Handle: "m10", Title: "another"})
|
||||
if len(*shown) != 3 || (*shown)[1].args["replace_id"] != 1 || (*shown)[1].args["summary"] != "Cleared: a new build can be delivered" {
|
||||
t.Fatalf("the clearing did not replace the urgent notification: %+v", *shown)
|
||||
}
|
||||
@@ -423,7 +423,7 @@ func TestAFailedReplaceKeepsTheNotificationItWasToReplace(t *testing.T) {
|
||||
}
|
||||
|
||||
d, shown, said, store := newDesk(t, "laptop", "desktop")
|
||||
take(t, d, asks.Send, asks.Message{Handle: "m1", Title: "2 failed services on shanks"})
|
||||
take(t, d, channel.Send, channel.Message{Handle: "m1", Title: "2 failed services on shanks"})
|
||||
first := store["m1"].On
|
||||
notify := d.Notify
|
||||
d.Notify = func(machine string, args map[string]any) (json.RawMessage, error) {
|
||||
@@ -432,13 +432,13 @@ func TestAFailedReplaceKeepsTheNotificationItWasToReplace(t *testing.T) {
|
||||
}
|
||||
return notify(machine, args)
|
||||
}
|
||||
take(t, d, asks.Send, asks.Message{Handle: "m1", Title: "Still open: 2 failed services on shanks"})
|
||||
take(t, d, channel.Send, channel.Message{Handle: "m1", Title: "Still open: 2 failed services on shanks"})
|
||||
if got := store["m1"].On["desktop"]; got != first["desktop"] {
|
||||
t.Fatalf("the desktop's notification %d was forgotten: %v", first["desktop"], store["m1"].On)
|
||||
}
|
||||
d.Notify = func(string, map[string]any) (json.RawMessage, error) { return nil, errors.New("away") }
|
||||
take(t, d, asks.Send, asks.Message{Handle: "m1", Title: "Now urgent: 2 failed services on shanks"})
|
||||
if len(said[asks.IntakeSubject(asks.FailedWhat, Kind)]) != 1 {
|
||||
take(t, d, channel.Send, channel.Message{Handle: "m1", Title: "Now urgent: 2 failed services on shanks"})
|
||||
if len(said[channel.IntakeSubject(channel.FailedWhat, Kind)]) != 1 {
|
||||
t.Fatalf("shown nowhere, and not said as failed")
|
||||
}
|
||||
_ = shown
|
||||
|
||||
@@ -18,8 +18,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
"git.novox.be/novox/mesh-sdk/go/handacts"
|
||||
)
|
||||
|
||||
@@ -186,8 +186,8 @@ func hearing(c *Config) {
|
||||
go func() {
|
||||
<-stdio.Served() // subscribed as soon as the runtime serves, never after a guessed wait: an event sent meanwhile is heard
|
||||
for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) {
|
||||
err := stdio.Subscribe(asks.Seat+".decided."+Module, func(e stdio.Envelope) error {
|
||||
var w asks.Warrant
|
||||
err := stdio.Subscribe(oc.Seat+".decided."+Module, func(e stdio.Envelope) error {
|
||||
var w oc.Warrant
|
||||
if json.Unmarshal(e.Body, &w) == nil {
|
||||
for _, s := range c.HeardEnd(w) {
|
||||
logf("[mesh-config] %s", s)
|
||||
|
||||
@@ -22,7 +22,7 @@ import (
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/sdk/views"
|
||||
"git.novox.be/novox/mesh-sdk/go/views"
|
||||
)
|
||||
|
||||
// The tools of the owners that mesh-config asks, by the state they read.
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/sdk/views"
|
||||
"git.novox.be/novox/mesh-sdk/go/views"
|
||||
)
|
||||
|
||||
// CAT-C11 (ADR 0615 §1): a followed state of the delivery owners is read from the picture of its owner's tool and events:
|
||||
|
||||
@@ -16,8 +16,8 @@ package main
|
||||
//
|
||||
// **On the answer** mesh-config reads the router's own record of its ask (which only the router writes, and this
|
||||
// module reads under its own name alone), composes the ask again from the proposal and holds the warrant to it:
|
||||
// the same ask digest, an option it offered, at its level, in time (asks.Warrant.For), and the act the option
|
||||
// bound is this proposal's (asks.Option.Performs). A record whose value or shown value changed after it was asked
|
||||
// the same ask digest, an option it offered, at its level, in time (oc.Warrant.For), and the act the option
|
||||
// bound is this proposal's (oc.Option.Performs). A record whose value or shown value changed after it was asked
|
||||
// is refused, and so is an Approve when the value stored at the level is no longer the one the operator was shown
|
||||
// the change against. Then the value is set exactly as a set at the terminal is, with the same judgement and
|
||||
// history, and who approved it is kept beside the value; the warrant is recorded in the controller's hand-act log.
|
||||
@@ -34,8 +34,10 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
|
||||
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
ch "git.novox.be/novox/mesh-sdk/go/channel"
|
||||
"git.novox.be/novox/mesh-sdk/go/outward"
|
||||
)
|
||||
|
||||
@@ -47,7 +49,7 @@ const ProposalVerb = Seat + "." + Verb
|
||||
|
||||
// ProposalLasts is how long the operator has to answer: a day less ten minutes (ADR 0277 §9), under the router's
|
||||
// bound for an approval.
|
||||
const ProposalLasts = asks.ApproveLasts - 10*time.Minute
|
||||
const ProposalLasts = occ.ApproveLasts - 10*time.Minute
|
||||
|
||||
// How long a proposal waits for the router's word on its new ask, and how often it looks (ADR 0277 §20).
|
||||
const (
|
||||
@@ -182,8 +184,8 @@ func (p Proposal) about() string {
|
||||
|
||||
// Act is what an answer binds: this proposal's level, key, the digest of its exact value and of the value it was
|
||||
// shown against, who proposed it and when, and the answer.
|
||||
func (p Proposal) Act(option string) asks.Act {
|
||||
act := asks.Act{"verb": ProposalVerb, "option": option, "proposal": p.ID, "action": p.Action,
|
||||
func (p Proposal) Act(option string) occ.Act {
|
||||
act := occ.Act{"verb": ProposalVerb, "option": option, "proposal": p.ID, "action": p.Action,
|
||||
"level": p.Level.Kind, "node": p.Level.Node, "module": p.Level.Module, "key": p.Key, "value": p.Digest,
|
||||
"was": p.WasDigest, "had-value": strconv.FormatBool(p.HadValue), "from": p.From,
|
||||
"at": p.At.UTC().Format(time.RFC3339Nano)}
|
||||
@@ -273,7 +275,7 @@ func (p Proposal) summary() string {
|
||||
s := fmt.Sprintf("Holds %s for %s on %s for %s; approved, it takes effect when that pull request merges, "+
|
||||
"and is dropped if it closes unmerged. Nothing reaches a machine before the merge.", p.Key, p.Level.Module,
|
||||
p.Level.Node, p.Pull)
|
||||
if len([]rune(s)) > asks.SummaryLength {
|
||||
if len([]rune(s)) > occ.SummaryLength {
|
||||
s = fmt.Sprintf("Holds %s for %s on %s for %s until it merges; dropped if it closes unmerged.", p.Key,
|
||||
p.Level.Module, p.Level.Node, p.Pull)
|
||||
}
|
||||
@@ -282,7 +284,7 @@ func (p Proposal) summary() string {
|
||||
reach := p.reaches()
|
||||
s := fmt.Sprintf("%s%s at %s; approved, it takes effect at once on every node it reaches: %s.", verb, trusted,
|
||||
p.Level, reach)
|
||||
if len([]rune(s)) > asks.SummaryLength {
|
||||
if len([]rune(s)) > occ.SummaryLength {
|
||||
s = fmt.Sprintf("%s%s at %s; approved, it takes effect at once on every node it reaches (%d modules).", verb,
|
||||
trusted, p.Level, len(p.Reaches))
|
||||
}
|
||||
@@ -292,8 +294,8 @@ func (p Proposal) summary() string {
|
||||
// point is a line cut to what a phone shows at a glance, saying so.
|
||||
func point(line string) string {
|
||||
const cut = "… (cut: read it in the note)"
|
||||
if r := []rune(line); len(r) > asks.PointLength {
|
||||
return string(r[:asks.PointLength-len([]rune(cut))]) + cut
|
||||
if r := []rune(line); len(r) > occ.PointLength {
|
||||
return string(r[:occ.PointLength-len([]rune(cut))]) + cut
|
||||
}
|
||||
return line
|
||||
}
|
||||
@@ -316,7 +318,7 @@ func (p Proposal) headline() string {
|
||||
}
|
||||
for _, h := range []string{fmt.Sprintf("%s %s %s?", verb, p.Key, where), fmt.Sprintf("%s a config value %s?", verb,
|
||||
where), verb + " a config value?"} {
|
||||
if len([]rune(h)) <= asks.HeadlineLength {
|
||||
if len([]rune(h)) <= occ.HeadlineLength {
|
||||
if s, ok := sayable(h); ok {
|
||||
return s
|
||||
}
|
||||
@@ -327,7 +329,7 @@ func (p Proposal) headline() string {
|
||||
|
||||
// Ask is the proposal's ask, composed from it alone: its words, its two options each binding its act, who
|
||||
// answers, its expiry and what it is about.
|
||||
func (p Proposal) Ask() asks.Ask {
|
||||
func (p Proposal) Ask() oc.Ask {
|
||||
why, _ := sayable("Why: " + p.Why)
|
||||
if why != "Why: "+p.Why {
|
||||
why = "Why: given, and read in full with the proposal"
|
||||
@@ -348,8 +350,8 @@ func (p Proposal) Ask() asks.Ask {
|
||||
"proposal %s, through the mesh MCP server.", p.ID)
|
||||
return d.String()
|
||||
}
|
||||
a := asks.Ask{ID: p.ID, Headline: p.headline(), Explanation: compose(false), Who: asks.Operator,
|
||||
Expires: p.At.Add(ProposalLasts), OnExpiry: "the proposal is discarded; nothing changes", About: p.about(),
|
||||
a := oc.Ask{ID: p.ID, Headline: p.headline(), Explanation: compose(false), Who: occ.Operator,
|
||||
Expires: occ.FormatTime(p.At.Add(ProposalLasts)), OnExpiry: "the proposal is discarded; nothing changes", About: p.about(),
|
||||
Details: details(false)}
|
||||
if w := compose(true); w != a.Explanation {
|
||||
a.Whole = w
|
||||
@@ -368,8 +370,8 @@ func (p Proposal) Ask() asks.Ask {
|
||||
{OptionApprove, "Approve", does},
|
||||
{OptionDecline, "Decline", "nothing changes; the proposal is discarded"},
|
||||
} {
|
||||
binds, _ := asks.ActDigest(p.Act(o.id))
|
||||
a.Options = append(a.Options, asks.Option{ID: o.id, Label: o.label, Does: o.does, Level: asks.Approve,
|
||||
binds, _ := occ.ActDigest(p.Act(o.id))
|
||||
a.Options = append(a.Options, oc.Option{ID: o.id, Label: o.label, Does: o.does, Level: occ.Approve,
|
||||
Binds: binds})
|
||||
}
|
||||
return a
|
||||
@@ -379,7 +381,7 @@ func (p Proposal) Ask() asks.Ask {
|
||||
// acted on, so a record whose value or shown value changed after it was asked is refused (ADR 0277 §12).
|
||||
func (p Proposal) problem() string {
|
||||
switch {
|
||||
case !asks.UsableID(p.ID):
|
||||
case !occ.UsableID(p.ID):
|
||||
return "its id is not one"
|
||||
case p.Action != "set" && p.Action != "clear":
|
||||
return "its action is neither set nor clear"
|
||||
@@ -426,7 +428,7 @@ func approverReady(bus AskBus) string {
|
||||
}
|
||||
channels := 0
|
||||
for _, k := range kinds {
|
||||
if k.Seat != asks.ChannelSeat {
|
||||
if k.Seat != ch.ChannelSeat {
|
||||
continue
|
||||
}
|
||||
channels++
|
||||
@@ -446,9 +448,15 @@ func newProposalID() string {
|
||||
return "cfg-" + hex.EncodeToString(b[:])
|
||||
}
|
||||
|
||||
// warrantAt is the time a warrant carries; the zero time when it carries none.
|
||||
func warrantAt(w oc.Warrant) time.Time {
|
||||
t, _ := occ.Time(w.At)
|
||||
return t
|
||||
}
|
||||
|
||||
// endedWord is the router's word that an ask ended without a choice, heard while its proposer waits.
|
||||
type endedWord struct {
|
||||
Outcome asks.Outcome
|
||||
Outcome string
|
||||
Words string
|
||||
}
|
||||
|
||||
@@ -496,8 +504,9 @@ func (c *Config) Propose(call Call) (*ProposeAnswer, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
askExpires, _ := occ.Time(ask.Expires)
|
||||
answer := &ProposeAnswer{Proposal: prop.ID, Asked: ask.Headline, Fingerprint: prop.Fingerprint(),
|
||||
Expires: ask.Expires, Reaches: prop.Reaches, Replaced: replaced,
|
||||
Expires: askExpires, Reaches: prop.Reaches, Replaced: replaced,
|
||||
Router: "the router has not said yet whether it took the ask; config's proposals shows it"}
|
||||
// The router's word, before answering (ADR 0277 §20): it refuses at once an ask no channel can carry.
|
||||
within, every := c.RouterWithin, c.RouterEvery
|
||||
@@ -520,14 +529,14 @@ func (c *Config) Propose(call Call) (*ProposeAnswer, error) {
|
||||
answer.Said = fmt.Sprintf("proposed %s of %s at %s (fingerprint %s): the operator is asked %q on a channel that "+
|
||||
"proves who answered. Nothing changes until they approve; on Approve it takes effect at once, and on Decline "+
|
||||
"or at %s UTC it is discarded", action, prop.Key, prop.Level, prop.Fingerprint(), ask.Headline,
|
||||
ask.Expires.UTC().Format("15:04 on 2 Jan"))
|
||||
askExpires.UTC().Format("15:04 on 2 Jan"))
|
||||
return answer, nil
|
||||
}
|
||||
|
||||
// propose is Propose's judgement, its record and its ask; the caller holds the lock.
|
||||
func (c *Config) propose(call Call, action string) (Proposal, asks.Ask, []string, error) {
|
||||
fail := func(format string, a ...any) (Proposal, asks.Ask, []string, error) {
|
||||
return Proposal{}, asks.Ask{}, nil, errors.New(fmt.Sprintf(format, a...) + ". Nothing was proposed")
|
||||
func (c *Config) propose(call Call, action string) (Proposal, oc.Ask, []string, error) {
|
||||
fail := func(format string, a ...any) (Proposal, oc.Ask, []string, error) {
|
||||
return Proposal{}, oc.Ask{}, nil, errors.New(fmt.Sprintf(format, a...) + ". Nothing was proposed")
|
||||
}
|
||||
level, err := call.level()
|
||||
if err != nil {
|
||||
@@ -546,7 +555,7 @@ func (c *Config) propose(call Call, action string) (Proposal, asks.Ask, []string
|
||||
}
|
||||
s, err := c.sources()
|
||||
if err != nil {
|
||||
return Proposal{}, asks.Ask{}, nil, err
|
||||
return Proposal{}, oc.Ask{}, nil, err
|
||||
}
|
||||
// **Judged as a set judges it, before anyone is asked** (ADR 0277 §4): no ask for a value the verb would refuse.
|
||||
var r rule
|
||||
@@ -637,7 +646,7 @@ func (c *Config) propose(call Call, action string) (Proposal, asks.Ask, []string
|
||||
// replaces an older one still open (§10).
|
||||
all, err := c.proposals()
|
||||
if err != nil {
|
||||
return Proposal{}, asks.Ask{}, nil, err
|
||||
return Proposal{}, oc.Ask{}, nil, err
|
||||
}
|
||||
var replace []Proposal
|
||||
open := 0
|
||||
@@ -654,15 +663,15 @@ func (c *Config) propose(call Call, action string) (Proposal, asks.Ask, []string
|
||||
}
|
||||
open++
|
||||
}
|
||||
if open >= asks.MostOpen {
|
||||
if open >= occ.MostOpen {
|
||||
return fail("%d proposals already wait for the operator's answer, and mesh-config asks at most %d at once; "+
|
||||
"config's proposals lists them", open, asks.MostOpen)
|
||||
"config's proposals lists them", open, occ.MostOpen)
|
||||
}
|
||||
if why := approverReady(c.Asks); why != "" {
|
||||
return fail("%s; %s", why, AtTheTerminalInstead)
|
||||
}
|
||||
ask := prop.Ask()
|
||||
if err := ask.Check(now); err != nil {
|
||||
if err := occ.Check(ask, now); err != nil {
|
||||
return fail("%v", err)
|
||||
}
|
||||
if err := c.Store.Put(ProposalsState, prop.ID, prop); err != nil {
|
||||
@@ -675,11 +684,11 @@ func (c *Config) propose(call Call, action string) (Proposal, asks.Ask, []string
|
||||
replaced = append(replaced, o.ID)
|
||||
}
|
||||
}
|
||||
if err := c.Asks.Publish(asks.AskSubject(Module), ask, ask.ID); err != nil {
|
||||
if err := c.Asks.Publish(oc.AcceptSubjectAsk(Module), ask, ask.ID); err != nil {
|
||||
prop.State, prop.Outcome = ProposalUnasked, "the ask could not be published: "+err.Error()
|
||||
_ = c.Store.Put(ProposalsState, prop.ID, prop)
|
||||
c.logf("[mesh-config] proposal %s could not be asked: %v", prop.ID, err)
|
||||
return Proposal{}, asks.Ask{}, nil, fmt.Errorf("the operator could not be asked (%v); the proposal is kept "+
|
||||
return Proposal{}, oc.Ask{}, nil, fmt.Errorf("the operator could not be asked (%v); the proposal is kept "+
|
||||
"and will never be set; %s", err, AtTheTerminalInstead)
|
||||
}
|
||||
c.logf("[mesh-config] proposal %s (%s %s at %s, fingerprint %s) asked of the operator", prop.ID, action, prop.Key,
|
||||
@@ -709,9 +718,9 @@ func (c *Config) proposals() ([]Proposal, error) {
|
||||
|
||||
// routerRecord is the router's record of one ask, as far as this module reads it.
|
||||
type routerRecord struct {
|
||||
State string `json:"state"`
|
||||
Ask *asks.Ask `json:"ask"`
|
||||
Warrant *asks.Warrant `json:"warrant"`
|
||||
State string `json:"state"`
|
||||
Ask *oc.Ask `json:"ask"`
|
||||
Warrant *oc.Warrant `json:"warrant"`
|
||||
}
|
||||
|
||||
// Decide decides every waiting proposal whose ask the router has closed, oldest warrant first, each held to the
|
||||
@@ -730,7 +739,7 @@ func (c *Config) Decide() ([]string, error) {
|
||||
now := c.now()
|
||||
type closed struct {
|
||||
p Proposal
|
||||
w asks.Warrant
|
||||
w oc.Warrant
|
||||
}
|
||||
var ready []closed
|
||||
var said []string
|
||||
@@ -754,7 +763,7 @@ func (c *Config) Decide() ([]string, error) {
|
||||
}
|
||||
ready = append(ready, closed{p, *rec.Warrant})
|
||||
}
|
||||
sort.SliceStable(ready, func(i, j int) bool { return ready[i].w.At.Before(ready[j].w.At) })
|
||||
sort.SliceStable(ready, func(i, j int) bool { return warrantAt(ready[i].w).Before(warrantAt(ready[j].w)) })
|
||||
for _, r := range ready {
|
||||
said = append(said, c.decide(r.p, r.w))
|
||||
}
|
||||
@@ -775,7 +784,7 @@ func (c *Config) end(p Proposal, why string) string {
|
||||
|
||||
// decide holds one closed ask's warrant to its proposal and, on an Approve that holds, sets the value. The caller
|
||||
// holds the lock.
|
||||
func (c *Config) decide(p Proposal, w asks.Warrant) string {
|
||||
func (c *Config) decide(p Proposal, w oc.Warrant) string {
|
||||
now := c.now()
|
||||
keep := func(state, outcome string) string {
|
||||
p.State, p.Outcome, p.Decided = state, outcome, now
|
||||
@@ -791,7 +800,7 @@ func (c *Config) decide(p Proposal, w asks.Warrant) string {
|
||||
if w.Asker != Module || w.Ask != p.ID {
|
||||
return refused(fmt.Sprintf("the router's record is for %s's ask %s", w.Asker, w.Ask))
|
||||
}
|
||||
if w.Outcome != asks.OutcomeChosen {
|
||||
if w.Outcome != occ.OutcomeChosen {
|
||||
why := "the ask " + string(w.Outcome)
|
||||
if w.Words != "" {
|
||||
why += " (" + w.Words + ")"
|
||||
@@ -804,19 +813,19 @@ func (c *Config) decide(p Proposal, w asks.Warrant) string {
|
||||
if problem := p.problem(); problem != "" {
|
||||
return refused("the proposal is not the one asked: " + problem)
|
||||
}
|
||||
option, err := w.For(Module, p.Ask())
|
||||
option, err := occ.For(w, Module, p.Ask())
|
||||
if err == nil {
|
||||
err = option.Performs(p.Act(option.ID))
|
||||
err = occ.Performs(option, p.Act(option.ID))
|
||||
}
|
||||
if err != nil {
|
||||
return refused(err.Error())
|
||||
}
|
||||
switch option.ID {
|
||||
case OptionDecline:
|
||||
p.Answered = w.Says()
|
||||
return keep(ProposalDeclined, "declined by the operator ("+w.Says()+"); nothing was set")
|
||||
p.Answered = occ.Says(w)
|
||||
return keep(ProposalDeclined, "declined by the operator ("+occ.Says(w)+"); nothing was set")
|
||||
case OptionApprove:
|
||||
p.Answered, p.Approved = w.Says(), approvedWords(p, w)
|
||||
p.Answered, p.Approved = occ.Says(w), approvedWords(p, w)
|
||||
default:
|
||||
return refused("the option " + option.ID + " is not one a proposal offers")
|
||||
}
|
||||
@@ -853,7 +862,7 @@ func (c *Config) decide(p Proposal, w asks.Warrant) string {
|
||||
}
|
||||
|
||||
// approvedWords is who approved it, as kept beside the value (ADR 0277 §15).
|
||||
func approvedWords(p Proposal, w asks.Warrant) string {
|
||||
func approvedWords(p Proposal, w oc.Warrant) string {
|
||||
who := "the operator"
|
||||
if w.By != nil {
|
||||
who = "the " + w.By.Who
|
||||
@@ -866,7 +875,7 @@ func approvedWords(p Proposal, w asks.Warrant) string {
|
||||
}
|
||||
who += ", via " + via
|
||||
}
|
||||
return fmt.Sprintf("approved by %s at %s, ask %s, proposed by %s", who, w.At.UTC().Format(time.RFC3339), p.ID, p.From)
|
||||
return fmt.Sprintf("approved by %s at %s, ask %s, proposed by %s", who, warrantAt(w).UTC().Format(time.RFC3339), p.ID, p.From)
|
||||
}
|
||||
|
||||
// recordWarrants asks the controller to record each warrant acted on (Approve or Decline) that its hand-act log
|
||||
@@ -892,11 +901,11 @@ func (c *Config) recordWarrants(now time.Time) {
|
||||
// HeardEnd takes the router's word on one of this module's asks. An ask ended without a choice ends its proposal
|
||||
// at once, and is told to a proposer waiting on it; nothing is set on such a word, so it needs no proof beyond its
|
||||
// subject. A choice is only ever taken from the router's record (Decide).
|
||||
func (c *Config) HeardEnd(w asks.Warrant) []string {
|
||||
if w.Asker != Module || !asks.UsableID(w.Ask) {
|
||||
func (c *Config) HeardEnd(w oc.Warrant) []string {
|
||||
if w.Asker != Module || !occ.UsableID(w.Ask) {
|
||||
return nil
|
||||
}
|
||||
if w.Outcome == asks.OutcomeChosen {
|
||||
if w.Outcome == occ.OutcomeChosen {
|
||||
said, err := c.Decide()
|
||||
if err != nil {
|
||||
said = append(said, "reading the operator's answers: "+err.Error())
|
||||
@@ -984,7 +993,7 @@ func (c *Config) Proposals(call Call) ([]ProposalRow, error) {
|
||||
switch {
|
||||
case rec.Ask == nil:
|
||||
row.Phone = "cannot be compared: the router's record keeps no ask"
|
||||
case rec.Ask.Digest() == p.Ask().Digest():
|
||||
case occ.Digest(*rec.Ask) == occ.Digest(p.Ask()):
|
||||
row.Phone = "shows the ask composed from exactly this proposal (fingerprint " + p.Fingerprint() + ")"
|
||||
default:
|
||||
row.Phone = "shows ANOTHER ask than this proposal composes: do not approve it"
|
||||
|
||||
@@ -10,8 +10,10 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
|
||||
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
ch "git.novox.be/novox/mesh-sdk/go/channel"
|
||||
)
|
||||
|
||||
// standIn is the operator channel's router and a channel that proves who answered, in memory: it takes the asks
|
||||
@@ -20,7 +22,7 @@ import (
|
||||
type standIn struct {
|
||||
mu sync.Mutex
|
||||
subjects []string
|
||||
asks map[string]asks.Ask
|
||||
asks map[string]oc.Ask
|
||||
records map[string][]byte
|
||||
kinds []stdio.KindHeld
|
||||
refuse error
|
||||
@@ -28,14 +30,14 @@ type standIn struct {
|
||||
// refuseWith makes the router refuse every ask it is published, as the router does for an ask no channel can
|
||||
// carry: no record, and its word on the decided subject (heard through hear).
|
||||
refuseWith string
|
||||
hear func(asks.Warrant)
|
||||
hear func(oc.Warrant)
|
||||
// noRecord keeps the router from recording an ask it took (an ask it has not taken yet).
|
||||
noRecord bool
|
||||
}
|
||||
|
||||
func newStandIn() *standIn {
|
||||
return &standIn{asks: map[string]asks.Ask{}, records: map[string][]byte{}, kinds: []stdio.KindHeld{
|
||||
{Seat: asks.ChannelSeat, Kind: "telegram", Module: "telegram", Node: "novox",
|
||||
return &standIn{asks: map[string]oc.Ask{}, records: map[string][]byte{}, kinds: []stdio.KindHeld{
|
||||
{Seat: ch.ChannelSeat, Kind: "telegram", Module: "telegram", Node: "novox",
|
||||
Capabilities: []string{"choice", "exact-render", "verified-sender"}}}}
|
||||
}
|
||||
|
||||
@@ -45,15 +47,15 @@ func (r *standIn) Publish(subject string, body any, id string) error {
|
||||
if r.refuse != nil {
|
||||
return r.refuse
|
||||
}
|
||||
a, ok := body.(asks.Ask)
|
||||
a, ok := body.(oc.Ask)
|
||||
if !ok || a.ID != id {
|
||||
return errors.New("not an ask")
|
||||
}
|
||||
r.subjects = append(r.subjects, subject)
|
||||
r.asks[id] = a
|
||||
if r.refuseWith != "" {
|
||||
w := asks.Warrant{Ask: id, Asker: Module, About: a.About, Outcome: asks.OutcomeRefused, Words: r.refuseWith,
|
||||
At: time.Now()}
|
||||
w := oc.Warrant{Ask: id, Asker: Module, About: a.About, Outcome: occ.OutcomeRefused, Words: r.refuseWith,
|
||||
At: occ.FormatTime(time.Now())}
|
||||
go r.hear(w)
|
||||
return nil
|
||||
}
|
||||
@@ -85,7 +87,7 @@ func (r *standIn) Warranted(ask string) error {
|
||||
|
||||
// choose is the operator choosing an option on a channel that proves who answered: the router's record of the ask
|
||||
// it took, closed with its warrant.
|
||||
func (r *standIn) choose(t *testing.T, id, option string, tweak func(*asks.Warrant)) asks.Warrant {
|
||||
func (r *standIn) choose(t *testing.T, id, option string, tweak func(*oc.Warrant)) oc.Warrant {
|
||||
t.Helper()
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
@@ -93,14 +95,14 @@ func (r *standIn) choose(t *testing.T, id, option string, tweak func(*asks.Warra
|
||||
if !ok {
|
||||
t.Fatalf("the router took no ask %s", id)
|
||||
}
|
||||
o, ok := a.Option(option)
|
||||
o, ok := occ.OptionOf(a, option)
|
||||
if !ok {
|
||||
t.Fatalf("the ask %s offers no %s", id, option)
|
||||
}
|
||||
w := asks.Warrant{Ask: id, Asker: Module, About: a.About, Outcome: asks.OutcomeChosen, Option: o.ID, Label: o.Label,
|
||||
Level: o.Level, By: &asks.Person{Who: asks.Operator, Kind: "telegram", Identity: "4242", Verified: "user id verified"},
|
||||
Channel: "telegram", Proofs: []string{"P1"}, At: time.Date(2026, 10, 14, 19, 30, 0, 0, time.UTC),
|
||||
AskDigest: a.Digest()}
|
||||
w := oc.Warrant{Ask: id, Asker: Module, About: a.About, Outcome: occ.OutcomeChosen, Option: o.ID, Label: o.Label,
|
||||
Level: o.Level, By: &oc.Person{Who: occ.Operator, Kind: "telegram", Identity: "4242", Verified: "user id verified"},
|
||||
Channel: "telegram", Proofs: []string{"P1"}, At: occ.FormatTime(time.Date(2026, 10, 14, 19, 30, 0, 0, time.UTC)),
|
||||
AskDigest: occ.Digest(a)}
|
||||
if tweak != nil {
|
||||
tweak(&w)
|
||||
}
|
||||
@@ -114,7 +116,7 @@ func aProposingMesh(t *testing.T) (*Config, *memStore, *standIn) {
|
||||
t.Helper()
|
||||
c, m := aMesh(t)
|
||||
r := newStandIn()
|
||||
r.hear = func(w asks.Warrant) { c.HeardEnd(w) }
|
||||
r.hear = func(w oc.Warrant) { c.HeardEnd(w) }
|
||||
c.Asks, c.RouterWithin, c.RouterEvery = r, 300*time.Millisecond, 5*time.Millisecond
|
||||
return c, m, r
|
||||
}
|
||||
@@ -180,14 +182,14 @@ func TestATrustedValueIsProposedAskedUnderItsOwnNameAndSetOnTheOperatorsApprove(
|
||||
t.Errorf("the proposal answered %+v", a)
|
||||
}
|
||||
// Asked under mesh-config's own name, on the operator channel.
|
||||
if len(r.subjects) != 1 || r.subjects[0] != asks.AskSubject("mesh-config") {
|
||||
if len(r.subjects) != 1 || r.subjects[0] != oc.AcceptSubjectAsk("mesh-config") {
|
||||
t.Fatalf("asked on %v", r.subjects)
|
||||
}
|
||||
ask := r.asks[a.Proposal]
|
||||
if err := ask.Check(c.now()); err != nil {
|
||||
if err := occ.Check(ask, c.now()); err != nil {
|
||||
t.Fatalf("the ask: %v", err)
|
||||
}
|
||||
if err := asks.Glance(ask.Explanation); err != nil {
|
||||
if err := occ.Glance(ask.Explanation); err != nil {
|
||||
t.Errorf("the ask does not read at a glance: %v\n%s", err, ask.Explanation)
|
||||
}
|
||||
for _, want := range []string{"agent-managed-settings", "mesh config", "takes effect at once", "claude-code",
|
||||
@@ -211,11 +213,11 @@ func TestATrustedValueIsProposedAskedUnderItsOwnNameAndSetOnTheOperatorsApprove(
|
||||
// Each option binds the level, the key and the value.
|
||||
p := storedProposal(t, m, a.Proposal)
|
||||
for _, o := range ask.Options {
|
||||
if o.Level != asks.Approve {
|
||||
if o.Level != occ.Approve {
|
||||
t.Errorf("the option %s is at %s", o.ID, o.Level)
|
||||
}
|
||||
act := p.Act(o.ID)
|
||||
want, _ := asks.ActDigest(act)
|
||||
want, _ := occ.ActDigest(act)
|
||||
if o.Binds != want || act["level"] != LevelMesh || act["key"] != "agent-managed-settings" ||
|
||||
act["value"] != valueDigest(json.RawMessage(`{"permissions":{"deny":["Bash(ssh:*)"]}}`), true) ||
|
||||
act["was"] != valueDigest(nil, false) {
|
||||
@@ -338,13 +340,13 @@ func TestAnApproveIsRefusedWhenTheProposalChangedAfterItWasAsked(t *testing.T) {
|
||||
func TestAWarrantThatDoesNotAnswerTheAskIsRefused(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
tweak func(w *asks.Warrant)
|
||||
tweak func(w *oc.Warrant)
|
||||
}{
|
||||
{"another ask's digest", func(w *asks.Warrant) { w.AskDigest = "sha256:00" }},
|
||||
{"another level", func(w *asks.Warrant) { w.Level = asks.Acknowledge }},
|
||||
{"after its expiry", func(w *asks.Warrant) { w.At = time.Date(2026, 10, 16, 0, 0, 0, 0, time.UTC) }},
|
||||
{"an option it did not offer", func(w *asks.Warrant) { w.Option = "force" }},
|
||||
{"no person", func(w *asks.Warrant) { w.By = nil }},
|
||||
{"another ask's digest", func(w *oc.Warrant) { w.AskDigest = "sha256:00" }},
|
||||
{"another level", func(w *oc.Warrant) { w.Level = occ.Acknowledge }},
|
||||
{"after its expiry", func(w *oc.Warrant) { w.At = occ.FormatTime(time.Date(2026, 10, 16, 0, 0, 0, 0, time.UTC)) }},
|
||||
{"an option it did not offer", func(w *oc.Warrant) { w.Option = "force" }},
|
||||
{"no person", func(w *oc.Warrant) { w.By = nil }},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
c, m, r := aProposingMesh(t)
|
||||
@@ -379,7 +381,7 @@ func TestDeclineExpiryAndTheRoutersRefusalSetNothing(t *testing.T) {
|
||||
|
||||
// Ended without a choice: the router's word.
|
||||
a = propose(t, c, Call{Key: "font-size", Value: 12.0, Node: "g14"})
|
||||
c.HeardEnd(asks.Warrant{Ask: a.Proposal, Asker: Module, Outcome: asks.OutcomeExpired, At: time.Now()})
|
||||
c.HeardEnd(oc.Warrant{Ask: a.Proposal, Asker: Module, Outcome: occ.OutcomeExpired, At: occ.FormatTime(time.Now())})
|
||||
if p := storedProposal(t, m, a.Proposal); p.State != ProposalEnded || !strings.Contains(p.Outcome, "expired") {
|
||||
t.Errorf("the expired proposal is %+v", p)
|
||||
}
|
||||
@@ -457,7 +459,7 @@ func TestAProposalIsJudgedAndRefusedBeforeTheOperatorIsAsked(t *testing.T) {
|
||||
t.Fatalf("a refused proposal was asked: %v", r.subjects)
|
||||
}
|
||||
|
||||
r.kinds = []stdio.KindHeld{{Seat: asks.ChannelSeat, Kind: "desktop", Capabilities: []string{"choice"}}}
|
||||
r.kinds = []stdio.KindHeld{{Seat: ch.ChannelSeat, Kind: "desktop", Capabilities: []string{"choice"}}}
|
||||
if _, err := c.Propose(Call{Action: "propose", Key: "font-size", Value: 13.0, Caller: "g", Why: "w"}); err == nil ||
|
||||
!strings.Contains(err.Error(), "no channel that proves who answered") {
|
||||
t.Errorf("no approving channel: %v", err)
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
|
||||
)
|
||||
|
||||
// Issue 1826 (novox/hq ADR 0502): a value held for the open pull request that introduces its key.
|
||||
@@ -174,7 +176,7 @@ func TestAHeldValueIsAskedAndKeptApartOnApprove(t *testing.T) {
|
||||
t.Errorf("a held value reaches %v", a.Reaches)
|
||||
}
|
||||
ask := r.asks[a.Proposal]
|
||||
if err := ask.Check(c.now()); err != nil {
|
||||
if err := occ.Check(ask, c.now()); err != nil {
|
||||
t.Fatalf("the ask: %v", err)
|
||||
}
|
||||
for _, want := range []string{thePull, "merges", "dropped if it closes unmerged", "media-gid", "n8n", "ace"} {
|
||||
|
||||
@@ -9,7 +9,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
|
||||
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
|
||||
"git.novox.be/novox/mesh-sdk/go/health"
|
||||
)
|
||||
|
||||
@@ -375,7 +376,7 @@ func TestARetirementDeclinedUnansweredCancelledOrASecret(t *testing.T) {
|
||||
ask := retiringOf(t, c)[0].Ask
|
||||
|
||||
// Unanswered: the ask expires, a week passes, it is asked once more, expires again: a finding.
|
||||
r.choose(t, ask, OptionApprove, func(w *asks.Warrant) { w.Outcome = asks.OutcomeExpired })
|
||||
r.choose(t, ask, OptionApprove, func(w *oc.Warrant) { w.Outcome = occ.OutcomeExpired })
|
||||
c.Decide()
|
||||
if rows := retiringOf(t, c); rows[0].State != RetireUnanswered || len(findingsOf(t, m, FindingRetireUnanswered)) != 0 {
|
||||
t.Fatalf("after the first expiry: %+v", rows)
|
||||
@@ -386,7 +387,7 @@ func TestARetirementDeclinedUnansweredCancelledOrASecret(t *testing.T) {
|
||||
if rows[0].State != RetireAsked || len(r.asks) != 2 || rows[0].Ask == ask {
|
||||
t.Fatalf("after a week: %+v, %d asks", rows, len(r.asks))
|
||||
}
|
||||
r.choose(t, rows[0].Ask, OptionApprove, func(w *asks.Warrant) { w.Outcome = asks.OutcomeExpired })
|
||||
r.choose(t, rows[0].Ask, OptionApprove, func(w *oc.Warrant) { w.Outcome = occ.OutcomeExpired })
|
||||
c.Decide()
|
||||
if fs := findingsOf(t, m, FindingRetireUnanswered); len(fs) != 1 {
|
||||
t.Fatalf("findings %+v after two asks unanswered", fs)
|
||||
|
||||
@@ -20,7 +20,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
|
||||
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
|
||||
)
|
||||
|
||||
// The state, and where a retirement stands.
|
||||
@@ -300,13 +301,13 @@ func retireAskID(e Retiring, again bool) string {
|
||||
}
|
||||
|
||||
// act is what an answer binds: the retirement, the nodes it reaches, the ask and the answer.
|
||||
func (e Retiring) act(ask, option string) asks.Act {
|
||||
return asks.Act{"verb": RetireVerb, "option": option, "module": e.Module, "key": e.Key, "kind": e.Kind,
|
||||
func (e Retiring) act(ask, option string) occ.Act {
|
||||
return occ.Act{"verb": RetireVerb, "option": option, "module": e.Module, "key": e.Key, "kind": e.Kind,
|
||||
"nodes": strings.Join(e.Nodes, " "), "ask": ask}
|
||||
}
|
||||
|
||||
// retireAsk is the ask composed from the retirement alone.
|
||||
func (e Retiring) retireAsk(id string, at time.Time) asks.Ask {
|
||||
func (e Retiring) retireAsk(id string, at time.Time) oc.Ask {
|
||||
nodes := strings.Join(e.Nodes, ", ")
|
||||
if nodes == "" {
|
||||
nodes = "no node"
|
||||
@@ -315,17 +316,17 @@ func (e Retiring) retireAsk(id string, at time.Time) asks.Ask {
|
||||
if e.Kind == KindSecret {
|
||||
what = "secret"
|
||||
}
|
||||
a := asks.Ask{ID: id, Headline: fmt.Sprintf("Remove %s's %s %s?", e.Module, what, e.Key),
|
||||
a := oc.Ask{ID: id, Headline: fmt.Sprintf("Remove %s's %s %s?", e.Module, what, e.Key),
|
||||
Explanation: fmt.Sprintf("No manifest reads %s's %s %s any more, and no node runs a build that reads it. Approved, it is removed on %s.", e.Module, what, e.Key, nodes),
|
||||
Who: asks.Operator, Expires: at.Add(ProposalLasts), OnExpiry: "nothing is removed; mesh-config asks once more in a week",
|
||||
Who: occ.Operator, Expires: occ.FormatTime(at.Add(ProposalLasts)), OnExpiry: "nothing is removed; mesh-config asks once more in a week",
|
||||
About: "config.retire." + e.Module + "." + e.Key,
|
||||
Details: fmt.Sprintf("Retiring since %s UTC. Read it with mesh-config's config_retiring.", e.Since.UTC().Format("15:04 on 2 Jan"))}
|
||||
for _, o := range []struct{ id, label, does string }{
|
||||
{OptionApprove, "Approve", "the " + what + " is removed on " + nodes},
|
||||
{OptionDecline, "Decline", "nothing is removed; mesh-config asks no more until it is read again"},
|
||||
} {
|
||||
binds, _ := asks.ActDigest(e.act(id, o.id))
|
||||
a.Options = append(a.Options, asks.Option{ID: o.id, Label: o.label, Does: o.does, Level: asks.Approve, Binds: binds})
|
||||
binds, _ := occ.ActDigest(e.act(id, o.id))
|
||||
a.Options = append(a.Options, oc.Option{ID: o.id, Label: o.label, Does: o.does, Level: occ.Approve, Binds: binds})
|
||||
}
|
||||
return a
|
||||
}
|
||||
@@ -338,10 +339,10 @@ func (c *Config) ask(e *Retiring, again bool) error {
|
||||
now := c.now()
|
||||
id := retireAskID(*e, again)
|
||||
a := e.retireAsk(id, now)
|
||||
if err := a.Check(now); err != nil {
|
||||
if err := occ.Check(a, now); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.Asks.Publish(asks.AskSubject(Module), a, a.ID); err != nil {
|
||||
if err := c.Asks.Publish(oc.AcceptSubjectAsk(Module), a, a.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
e.Ask, e.State, e.AskedAt = id, RetireAsked, now
|
||||
@@ -406,26 +407,26 @@ func (c *Config) decideRetirement(e *Retiring, now time.Time) []string {
|
||||
if w.Asker != Module || w.Ask != e.Ask {
|
||||
return keep(RetireUnanswered, "the router's record is for another ask; nothing is removed")
|
||||
}
|
||||
if w.Outcome != asks.OutcomeChosen {
|
||||
if w.Outcome != occ.OutcomeChosen {
|
||||
out := keep(RetireUnanswered, "the ask "+string(w.Outcome)+"; nothing is removed")
|
||||
if e.AskedAgain {
|
||||
c.setFinding(retireUnanswered(*e, now))
|
||||
}
|
||||
return out
|
||||
}
|
||||
option, err := w.For(Module, e.retireAsk(e.Ask, e.AskedAt))
|
||||
option, err := occ.For(w, Module, e.retireAsk(e.Ask, e.AskedAt))
|
||||
if err == nil {
|
||||
err = option.Performs(e.act(e.Ask, option.ID))
|
||||
err = occ.Performs(option, e.act(e.Ask, option.ID))
|
||||
}
|
||||
if err != nil {
|
||||
c.logf("[mesh-config] REFUSED the answer to the retirement ask %s: %v", e.Ask, err)
|
||||
return keep(RetireUnanswered, "the answer is refused and nothing is removed: "+err.Error())
|
||||
}
|
||||
c.clearFinding(FindingRetireUnanswered, e.Module, e.Key)
|
||||
e.Answered = w.Says()
|
||||
e.Answered = occ.Says(w)
|
||||
switch option.ID {
|
||||
case OptionDecline:
|
||||
return keep(RetireDeclined, "declined by the operator ("+w.Says()+"); nothing is removed")
|
||||
return keep(RetireDeclined, "declined by the operator ("+occ.Says(w)+"); nothing is removed")
|
||||
case OptionApprove:
|
||||
out := keep(RetireApproved, "approved by the operator; being removed")
|
||||
return append(out, c.finishRetirement(e)...)
|
||||
|
||||
@@ -31,7 +31,7 @@ import (
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
|
||||
"git.novox.be/novox/mesh-contracts/go/seats/meshconfig"
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/health"
|
||||
"git.novox.be/novox/mesh-sdk/go/testbus"
|
||||
@@ -319,7 +319,7 @@ func (p *player) handle(m wire) {
|
||||
p.mu.Unlock()
|
||||
p.answer(m.ID, map[string]any{}, nil)
|
||||
case "mesh/seat.publish":
|
||||
var ask asks.Ask
|
||||
var ask oc.Ask
|
||||
if err := json.Unmarshal(a.Body, &ask); err != nil {
|
||||
p.answer(m.ID, nil, err)
|
||||
return
|
||||
@@ -779,7 +779,7 @@ func TestConfigWholeRenamesAndRetiresAgainstTheBus(t *testing.T) {
|
||||
}
|
||||
router.mu.Unlock()
|
||||
w := router.choose(t, id, OptionApprove, nil)
|
||||
p2.event(asks.Seat+".decided."+Module, w)
|
||||
p2.event(oc.Seat+".decided."+Module, w)
|
||||
eventually(t, "the approved value is removed and retired is said", func() bool {
|
||||
_, still := p2.get(ValuesState, "module.g14.radio.band")
|
||||
return !still && p2.publishedCount(EventRetired) == 1
|
||||
|
||||
@@ -3,8 +3,10 @@ module mesh-config
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-delivery/sdk v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-contracts/go v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-contracts/go/seats/meshconfig v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-contracts/go/seats/operatorchannel v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128
|
||||
github.com/nats-io/nats.go v1.54.0
|
||||
)
|
||||
|
||||
@@ -9,8 +9,9 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
|
||||
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
sdk "git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
@@ -45,7 +46,7 @@ const (
|
||||
)
|
||||
|
||||
// AskLasts is how long the operator has to answer: a day less a margin, under the router's bound for an approval.
|
||||
const AskLasts = asks.ApproveLasts - 10*time.Minute
|
||||
const AskLasts = occ.ApproveLasts - 10*time.Minute
|
||||
|
||||
// Operator is how this owner asks the operator: through the keys of its bucket asks and the teller's answers. kvOperator in
|
||||
// the running module.
|
||||
@@ -81,8 +82,8 @@ func headsString(heads map[string]string) string {
|
||||
}
|
||||
|
||||
// mergeAct is what an answer binds: this ask, the answer, and the heads it covers.
|
||||
func mergeAct(ask, option string, heads map[string]string) asks.Act {
|
||||
return asks.Act{"verb": Seat + ".merge", "ask": ask, "option": option, "heads": headsString(heads)}
|
||||
func mergeAct(ask, option string, heads map[string]string) occ.Act {
|
||||
return occ.Act{"verb": Seat + ".merge", "ask": ask, "option": option, "heads": headsString(heads)}
|
||||
}
|
||||
|
||||
var unshownPath = regexp.MustCompile(`[/\\@~$]|\d+\.\d+\.\d+|[A-Za-z0-9+/=_]{24,}|(?i)\b[a-z0-9-]+\.[a-z]{2,12}\b`)
|
||||
@@ -183,9 +184,9 @@ func mergeHeadline(c checkSaid) string {
|
||||
// check is the state of the check at the heads it covers (checkAt): the ask says "passed its check" only when it did, and
|
||||
// otherwise that Approve merges before the check ends, or although it failed (issue 1733).
|
||||
func composeMergeAsk(id string, d *Delivery, group string, members []*Delivery, heads map[string]string, check checkSaid,
|
||||
now time.Time) asks.Ask {
|
||||
var a asks.Ask
|
||||
a.ID, a.Who, a.Expires = id, asks.Operator, now.Add(AskLasts)
|
||||
now time.Time) oc.Ask {
|
||||
var a oc.Ask
|
||||
a.ID, a.Who, a.Expires = id, occ.Operator, occ.FormatTime(now.Add(AskLasts))
|
||||
a.OnExpiry = "nothing is merged; the ask is made again"
|
||||
a.About = "delivery." + phone(repoName(d.Repository), 40) + "." + strconv.Itoa(d.Number)
|
||||
a.About = strings.ReplaceAll(a.About, " ", "-")
|
||||
@@ -203,24 +204,24 @@ func composeMergeAsk(id string, d *Delivery, group string, members []*Delivery,
|
||||
pullSaid(m), m.Title, shortOf(m.Commit, 12), issuesSaid([]*Delivery{m}), words, touches))
|
||||
}
|
||||
if group == "" {
|
||||
a.Headline = phone(mergeHeadline(check)+pullSaid(d)+" "+d.Title, asks.HeadlineLength)
|
||||
a.Headline = phone(mergeHeadline(check)+pullSaid(d)+" "+d.Title, occ.HeadlineLength)
|
||||
summary := fmt.Sprintf("%s %s%s. Approve merges it into main at %s%s; Decline leaves it unmerged.",
|
||||
pullSaid(d), d.Title, checkClause(check, " "), shortOf(d.Commit, 12), approveCaveat(check))
|
||||
if unreviewed != "" {
|
||||
summary = strings.ToUpper(unreviewed[:1]) + unreviewed[1:] + ". " + summary
|
||||
}
|
||||
a.Explanation = strings.Join([]string{
|
||||
phone(summary, asks.SummaryLength),
|
||||
phone("Issues: "+issuesSaid(members), asks.PointLength),
|
||||
phone("Review: "+reviewers[0], asks.PointLength),
|
||||
phone("Touches: "+reaches[0], asks.PointLength),
|
||||
phone(summary, occ.SummaryLength),
|
||||
phone("Issues: "+issuesSaid(members), occ.PointLength),
|
||||
phone("Review: "+reviewers[0], occ.PointLength),
|
||||
phone("Touches: "+reaches[0], occ.PointLength),
|
||||
}, "\n")
|
||||
} else {
|
||||
var names []string
|
||||
for _, m := range members {
|
||||
names = append(names, pullSaid(m))
|
||||
}
|
||||
a.Headline = phone(mergeHeadline(check)+"group "+group+" ("+strings.Join(names, ", ")+")", asks.HeadlineLength)
|
||||
a.Headline = phone(mergeHeadline(check)+"group "+group+" ("+strings.Join(names, ", ")+")", occ.HeadlineLength)
|
||||
caveat := approveCaveat(check)
|
||||
if caveat != "" {
|
||||
caveat = "," + caveat
|
||||
@@ -231,10 +232,10 @@ func composeMergeAsk(id string, d *Delivery, group string, members []*Delivery,
|
||||
summary = strings.ToUpper(unreviewed[:1]) + unreviewed[1:] + ". " + summary
|
||||
}
|
||||
a.Explanation = strings.Join([]string{
|
||||
phone(summary, asks.SummaryLength),
|
||||
phone("Issues: "+issuesSaid(members), asks.PointLength),
|
||||
phone("Review: "+strings.Join(reviewers, "; "), asks.PointLength),
|
||||
phone("Touches: "+strings.Join(reaches, "; "), asks.PointLength),
|
||||
phone(summary, occ.SummaryLength),
|
||||
phone("Issues: "+issuesSaid(members), occ.PointLength),
|
||||
phone("Review: "+strings.Join(reviewers, "; "), occ.PointLength),
|
||||
phone("Touches: "+strings.Join(reaches, "; "), occ.PointLength),
|
||||
}, "\n")
|
||||
}
|
||||
a.Details = phone(strings.Join(details, "\n"), 1500)
|
||||
@@ -251,8 +252,8 @@ func composeMergeAsk(id string, d *Delivery, group string, members []*Delivery,
|
||||
{OptionApprove, "Approve", "the pull request is merged into main at the head named (a group: all its members, in one pass)"},
|
||||
{OptionDecline, "Decline", "nothing is merged"},
|
||||
} {
|
||||
binds, _ := asks.ActDigest(mergeAct(id, o.id, heads))
|
||||
a.Options = append(a.Options, asks.Option{ID: o.id, Label: o.label, Does: o.does, Level: asks.Approve, Binds: binds})
|
||||
binds, _ := occ.ActDigest(mergeAct(id, o.id, heads))
|
||||
a.Options = append(a.Options, oc.Option{ID: o.id, Label: o.label, Does: o.does, Level: occ.Approve, Binds: binds})
|
||||
}
|
||||
return a
|
||||
}
|
||||
@@ -573,7 +574,11 @@ func (h *Holder) approval(d *Delivery, now time.Time) (string, func()) {
|
||||
h.keepGroup(g)
|
||||
h.mu.Unlock()
|
||||
}
|
||||
if err := h.Operator.Ask(id, sdk.Ask{Group: name, Heads: covered, Asking: asking, Ask: ask}); err == nil {
|
||||
toAsk, err := gateAsk(name, covered, asking, ask)
|
||||
if err == nil {
|
||||
err = h.Operator.Ask(id, toAsk)
|
||||
}
|
||||
if err == nil {
|
||||
h.mu.Lock()
|
||||
h.markWritten(id, true)
|
||||
h.mu.Unlock()
|
||||
@@ -787,11 +792,49 @@ func (h *Holder) answered(key string, ans sdk.Answer) []func() {
|
||||
return said
|
||||
}
|
||||
|
||||
// gateAsk is the gate's ask for a group at heads: the router's ask as its JSON in the value's ask, after the checks
|
||||
// mesh-sdk no longer makes for it (novox/hq ADR 0627 §4): the ask's id is the one its group, heads and asking give, and
|
||||
// it offers at least one option.
|
||||
func gateAsk(group string, heads []sdk.Head, asking int, ask oc.Ask) (sdk.Ask, error) {
|
||||
raw, err := json.Marshal(ask)
|
||||
if err != nil {
|
||||
return sdk.Ask{}, err
|
||||
}
|
||||
a := sdk.Ask{Group: group, Heads: heads, Asking: asking, Ask: raw}
|
||||
if _, err := askOf(a); err != nil {
|
||||
return sdk.Ask{}, err
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// askOf reads the router's ask a gate ask carries and holds it to what the gate owes: the id its group, heads and asking
|
||||
// give, and at least one option. kvOperator.Ask holds every ask it writes to it, too.
|
||||
func askOf(a sdk.Ask) (oc.Ask, error) {
|
||||
var ask oc.Ask
|
||||
if err := json.Unmarshal(a.Ask, &ask); err != nil {
|
||||
return oc.Ask{}, fmt.Errorf("the ask is not a router's ask: %w", err)
|
||||
}
|
||||
key, err := sdk.MergeAskKey(a.Group, a.Heads)
|
||||
if err != nil {
|
||||
return oc.Ask{}, fmt.Errorf("an ask needs its group and at least one pull request's head: %w", err)
|
||||
}
|
||||
if want := sdk.AskingID(key, a.Asking); ask.ID != want {
|
||||
return oc.Ask{}, fmt.Errorf("the ask's id %q is not %q, the id its group, heads and asking %d give", ask.ID, want, a.Asking)
|
||||
}
|
||||
if len(ask.Options) == 0 {
|
||||
return oc.Ask{}, fmt.Errorf("the ask has no option")
|
||||
}
|
||||
return ask, nil
|
||||
}
|
||||
|
||||
// kvOperator is the operator over the runtime: the ask is a key of the gate's bucket asks, the answer a key of the teller's
|
||||
// bucket answers.
|
||||
type kvOperator struct{}
|
||||
|
||||
func (kvOperator) Ask(key string, a sdk.Ask) error {
|
||||
if _, err := askOf(a); err != nil {
|
||||
return err
|
||||
}
|
||||
raw, err := sdk.EncodeAsk(a)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
|
||||
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
|
||||
sdk "git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
@@ -39,7 +41,7 @@ func TestAMarkedGreenDeliveryIntoMainIsAskedAndNotMergedUntilApproved(t *testing
|
||||
t.Fatalf("the operator is asked %v; wanted exactly one ask", open)
|
||||
}
|
||||
a := w.op.ask(open[0])
|
||||
if !strings.HasPrefix(a.Headline, "Merge into main: app#7") || a.Who != asks.Operator ||
|
||||
if !strings.HasPrefix(a.Headline, "Merge into main: app#7") || a.Who != occ.Operator ||
|
||||
len(a.Options) != 2 || a.Options[0].ID != OptionApprove || a.Options[1].ID != OptionDecline {
|
||||
t.Fatalf("the ask is not the merge ask: %+v", a)
|
||||
}
|
||||
@@ -48,7 +50,7 @@ func TestAMarkedGreenDeliveryIntoMainIsAskedAndNotMergedUntilApproved(t *testing
|
||||
t.Fatalf("the ask does not say %q: %s", want, a.Explanation)
|
||||
}
|
||||
}
|
||||
if err := asks.Glance(a.Explanation); err != nil {
|
||||
if err := occ.Glance(a.Explanation); err != nil {
|
||||
t.Fatalf("the ask does not read at a glance: %v", err)
|
||||
}
|
||||
// Ticks and minutes pass without an answer: not merged, not asked again.
|
||||
@@ -195,8 +197,9 @@ func TestAnUnansweredAskIsWrittenAgainOnceWhenItLapses(t *testing.T) {
|
||||
}
|
||||
// Review round 1, G2: the router keeps an ask by its id and drops a second under it, so the second asking has an id of its own.
|
||||
again := w.op.byKey[first]
|
||||
if again.Asking != 2 || again.Ask.ID != sdk.AskingID(first, 2) || again.Ask.ID == sdk.AskID(first) {
|
||||
t.Fatalf("the second asking is %d under the id %s; wanted asking 2 under %s", again.Asking, again.Ask.ID, sdk.AskingID(first, 2))
|
||||
againAsk := w.op.ask(first)
|
||||
if again.Asking != 2 || againAsk.ID != sdk.AskingID(first, 2) || againAsk.ID == sdk.AskID(first) {
|
||||
t.Fatalf("the second asking is %d under the id %s; wanted asking 2 under %s", again.Asking, againAsk.ID, sdk.AskingID(first, 2))
|
||||
}
|
||||
for range 3 {
|
||||
w.later(2 * time.Minute)
|
||||
@@ -474,3 +477,42 @@ func TestADeclineOfAnEarlierAskDoesNotHoldTheApproveOfTheNext(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// mesh-sdk no longer refuses an ask whose id is another's or that offers no option (novox/hq ADR 0627 §4, ruling 1): the gate
|
||||
// holds both, when it composes its ask and when kvOperator writes it.
|
||||
func TestTheGateRefusesAnAskWithAnotherIdOrNoOption(t *testing.T) {
|
||||
heads := []sdk.Head{{Repository: "novox/app", Number: 7, Head: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}
|
||||
key, err := sdk.MergeAskKey("feat/g", heads)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
option := oc.Option{ID: OptionApprove, Label: "Approve", Level: occ.Approve}
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
asking int
|
||||
ask oc.Ask
|
||||
refuse string
|
||||
}{
|
||||
{"the first asking's id", 1, oc.Ask{ID: sdk.AskingID(key, 1), Options: []oc.Option{option}}, ""},
|
||||
{"the second asking's id", 2, oc.Ask{ID: sdk.AskingID(key, 2), Options: []oc.Option{option}}, ""},
|
||||
{"another id", 1, oc.Ask{ID: "another-id", Options: []oc.Option{option}}, "is not"},
|
||||
{"the first asking's id at the second asking", 2, oc.Ask{ID: sdk.AskingID(key, 1), Options: []oc.Option{option}}, "is not"},
|
||||
{"no option", 1, oc.Ask{ID: sdk.AskingID(key, 1)}, "no option"},
|
||||
} {
|
||||
a, err := gateAsk("feat/g", heads, c.asking, c.ask)
|
||||
if c.refuse == "" {
|
||||
if err != nil {
|
||||
t.Errorf("%s: refused: %v", c.name, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), c.refuse) {
|
||||
t.Errorf("%s: composed (%v), wanted a refusal saying %q", c.name, err, c.refuse)
|
||||
}
|
||||
// The same ask written by hand, past gateAsk, is refused where kvOperator writes it.
|
||||
raw, _ := json.Marshal(c.ask)
|
||||
if err := (kvOperator{}).Ask(key, sdk.Ask{Group: "feat/g", Heads: heads, Asking: c.asking, Ask: raw}); err == nil || !strings.Contains(err.Error(), c.refuse) {
|
||||
t.Errorf("%s: kvOperator wrote it (%v, %v)", c.name, err, a)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,8 +16,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
sdk "git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"github.com/nats-io/nats-server/v2/server"
|
||||
"github.com/nats-io/nats.go"
|
||||
@@ -94,7 +94,7 @@ func newTestBus(t *testing.T) *testBus {
|
||||
}
|
||||
// The streams the events are on (mesh.mod.<module>.event.<name>, mesh.seat.<seat>.event.<name>).
|
||||
if _, err := js.CreateStream(context.Background(), jetstream.StreamConfig{Name: "EVENTS",
|
||||
Subjects: []string{"mesh.mod.gitea.event.>", "mesh.seat." + asks.Seat + ".event.>",
|
||||
Subjects: []string{"mesh.mod.gitea.event.>", "mesh.seat." + oc.Seat + ".event.>",
|
||||
"mesh.mod." + sdk.ForgeBridgeModule + ".event.>"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -117,7 +117,7 @@ type runtime struct {
|
||||
notify chan struct{}
|
||||
// the asks the operator was asked, and the controller's walks and the forge's open pull requests as the test plays
|
||||
// them.
|
||||
operatorAsks []asks.Ask
|
||||
operatorAsks []oc.Ask
|
||||
// operatorCancels are the ids of the asks taken back from the router, in order (issue 1079).
|
||||
operatorCancels []string
|
||||
openPulls []OpenPull
|
||||
@@ -296,7 +296,7 @@ func (r *runtime) answer(m rpcMessage) {
|
||||
case "mesh/seat.publish":
|
||||
// The ask for the operator, published under the module's name.
|
||||
if strings.Contains(p.Subject, ".accept.ask.") {
|
||||
var a asks.Ask
|
||||
var a oc.Ask
|
||||
if err := json.Unmarshal(p.Body, &a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -543,10 +543,13 @@ func TestAPassingVerdictAsksTheApproveAndItsApproveMergesWithinSecondsOverARealB
|
||||
"title": announced.Title, "head_sha": head, "base": "main", "body": "Reviewed-by: lead@shanks at " + head})
|
||||
var key string
|
||||
rt.until("the ask to be written", eventWithin, func() bool { key = onlyAsk(b); return key != "" })
|
||||
ask := askAt(t, b, key)
|
||||
ask, err := askOf(askAt(t, b, key))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Logf("the ask was written %s after the edit's event", time.Since(edit))
|
||||
if !strings.Contains(ask.Ask.Explanation, "reviewed by lead on shanks") || ask.Ask.ID != sdk.AskID(key) {
|
||||
t.Fatalf("the ask does not say the record: %s", ask.Ask.Explanation)
|
||||
if !strings.Contains(ask.Explanation, "reviewed by lead on shanks") || ask.ID != sdk.AskID(key) {
|
||||
t.Fatalf("the ask does not say the record: %s", ask.Explanation)
|
||||
}
|
||||
if len(rt.asksOf("gitea.gitea_merge_pull_request")) != 0 {
|
||||
t.Fatalf("merged before the Approve")
|
||||
@@ -666,7 +669,11 @@ func mergeAt(b *testBus, group string) sdk.Merge {
|
||||
func (b *testBus) answer(t *testing.T, key, option string) time.Time {
|
||||
t.Helper()
|
||||
// The warrant's id is the router id of the asking answered and the time (the teller's warrantID).
|
||||
raw, err := sdk.EncodeAnswer(sdk.Answer{Option: option, Warrant: askNowOrFatal(t, b, key).Ask.ID + "@2026-10-20T03:00:00Z", Words: "the operator, via telegram, chose " + option})
|
||||
asked, err := askOf(askNowOrFatal(t, b, key))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err := sdk.EncodeAnswer(sdk.Answer{Option: option, Warrant: asked.ID + "@2026-10-20T03:00:00Z", Words: "the operator, via telegram, chose " + option})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -7,8 +7,9 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
|
||||
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
)
|
||||
|
||||
// The gate says its own stalled lines to the operator as conditions (novox/hq issue 1275, met again 2026-10-25; ADR 0437
|
||||
@@ -49,12 +50,12 @@ func (kvConditions) PutSaid(said map[string]string) error {
|
||||
}
|
||||
|
||||
// sayOnSeat says a condition on the operator-channel seat under the gate's own name.
|
||||
func sayOnSeat(c asks.Condition) error {
|
||||
subject, err := asks.ConditionSubject("mesh-delivery-gate")
|
||||
func sayOnSeat(c oc.Condition) error {
|
||||
subject, err := occ.ConditionSubject("mesh-delivery-gate")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw, err := asks.EncodeCondition(c)
|
||||
raw, err := occ.EncodeCondition(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -69,26 +70,26 @@ func conditionKeyOf(id string) string { return "mesh-delivery-gate.delivery." +
|
||||
func conditionSaid(l StalledLine) string { return severityOfLine(l) + "|" + l.H2 + "|" + l.Needs }
|
||||
|
||||
func severityOfLine(l StalledLine) string {
|
||||
if l.Severity == asks.SeverityUrgent {
|
||||
return asks.SeverityUrgent
|
||||
if l.Severity == occ.SeverityUrgent {
|
||||
return occ.SeverityUrgent
|
||||
}
|
||||
return asks.SeverityWarning
|
||||
return occ.SeverityWarning
|
||||
}
|
||||
|
||||
// conditionOf is a stalled line as the condition the operator is told.
|
||||
func conditionOf(l StalledLine) asks.Condition {
|
||||
func conditionOf(l StalledLine) oc.Condition {
|
||||
headline := "stalled: " + l.ID
|
||||
if strings.HasPrefix(l.Says, "group ") {
|
||||
group, _, _ := strings.Cut(strings.TrimPrefix(l.Says, "group "), " ")
|
||||
headline = "group " + group + " is not merged whole"
|
||||
}
|
||||
if r := []rune(headline); len(r) > asks.HeadlineLength {
|
||||
headline = string(r[:asks.HeadlineLength-1]) + "…"
|
||||
if r := []rune(headline); len(r) > occ.HeadlineLength {
|
||||
headline = string(r[:occ.HeadlineLength-1]) + "…"
|
||||
}
|
||||
needs := "read mesh-delivery.show " + l.ID + " and decide"
|
||||
return asks.Condition{Key: conditionKeyOf(l.ID), Severity: severityOfLine(l), State: asks.StateRaised,
|
||||
Subject: asks.Scope{Scope: "delivery", ID: l.ID}, Summary: l.Says, Headline: headline, Needs: needs,
|
||||
Explanation: asks.NeedsYou + needs + ". " + l.Says + " What ends it: " + l.H2 + ".",
|
||||
return oc.Condition{Key: conditionKeyOf(l.ID), Severity: severityOfLine(l), State: occ.StateRaised,
|
||||
Subject: oc.ConditionSubject{Scope: "delivery", ID: l.ID}, Summary: l.Says, Headline: headline, Needs: needs,
|
||||
Explanation: occ.NeedsYou + needs + ". " + l.Says + " What ends it: " + l.H2 + ".",
|
||||
Resolved: "The gate lists " + l.ID + " as stalled no more."}
|
||||
}
|
||||
|
||||
@@ -121,7 +122,7 @@ func (h *Holder) sayConditions() {
|
||||
lines := map[string]StalledLine{}
|
||||
for _, l := range h.Stalled() {
|
||||
// One condition for an id: the most severe of its lines.
|
||||
if o, ok := lines[l.ID]; !ok || (severityOfLine(o) != asks.SeverityUrgent && severityOfLine(l) == asks.SeverityUrgent) {
|
||||
if o, ok := lines[l.ID]; !ok || (severityOfLine(o) != occ.SeverityUrgent && severityOfLine(l) == occ.SeverityUrgent) {
|
||||
lines[l.ID] = l
|
||||
}
|
||||
}
|
||||
@@ -158,7 +159,7 @@ func (h *Holder) sayConditions() {
|
||||
sort.Strings(gone)
|
||||
for _, key := range gone {
|
||||
id := strings.TrimSuffix(strings.TrimPrefix(key, "mesh-delivery-gate.delivery."), ".stalled")
|
||||
c := asks.Condition{Key: key, Subject: asks.Scope{Scope: "delivery", ID: id}, State: asks.StateCleared,
|
||||
c := oc.Condition{Key: key, Subject: oc.ConditionSubject{Scope: "delivery", ID: id}, State: occ.StateCleared,
|
||||
Resolved: "The gate lists " + id + " as stalled no more."}
|
||||
if err := h.Say(c); err != nil {
|
||||
h.Logf("[mesh-delivery] the condition of %s could not be cleared: %v", id, err)
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
|
||||
sdk "git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
@@ -41,7 +41,7 @@ type Holder struct {
|
||||
// Pulls keeps the pull requests' keys on the bus (pullkey.go); nil keeps none.
|
||||
Pulls PullKeys
|
||||
// Say says a condition to the operator, and Conditions keeps which the gate raised (conditions.go); nil says none.
|
||||
Say func(c asks.Condition) error
|
||||
Say func(c oc.Condition) error
|
||||
Conditions ConditionStore
|
||||
// said is what Conditions keeps, read at the first look; sayFailures the looks in a row a condition could not be
|
||||
// said. Touched by React alone.
|
||||
|
||||
@@ -8,7 +8,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
|
||||
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
|
||||
sdk "git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
@@ -337,7 +338,7 @@ func TestASeverityOnceUrgentNeverStepsDownToAWarning(t *testing.T) {
|
||||
type fakeConditions struct {
|
||||
mu sync.Mutex
|
||||
kept map[string]string
|
||||
told []asks.Condition
|
||||
told []oc.Condition
|
||||
}
|
||||
|
||||
func (f *fakeConditions) Said() (map[string]string, error) {
|
||||
@@ -360,8 +361,8 @@ func (f *fakeConditions) PutSaid(said map[string]string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeConditions) say(c asks.Condition) error {
|
||||
if _, err := asks.EncodeCondition(c); err != nil {
|
||||
func (f *fakeConditions) say(c oc.Condition) error {
|
||||
if _, err := occ.EncodeCondition(c); err != nil {
|
||||
return err // what the router would refuse
|
||||
}
|
||||
f.mu.Lock()
|
||||
@@ -371,7 +372,7 @@ func (f *fakeConditions) say(c asks.Condition) error {
|
||||
}
|
||||
|
||||
// last is the newest condition said for a delivery id, or nil.
|
||||
func (f *fakeConditions) last(id string) *asks.Condition {
|
||||
func (f *fakeConditions) last(id string) *oc.Condition {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
for i := len(f.told) - 1; i >= 0; i-- {
|
||||
@@ -487,7 +488,7 @@ func TestAnApprovedGroupIsHandedWholeInItsOrderAndAStopIsSaid(t *testing.T) {
|
||||
!strings.Contains(lines[0].Says, "mesh/repo-check (missing)") || !strings.Contains(lines[0].Says, "Needed") {
|
||||
t.Fatalf("not said as one urgent line: %+v", lines)
|
||||
}
|
||||
if c := told.last(IDOf(gCat, gHk)); c == nil || c.State != asks.StateRaised || c.Severity != asks.SeverityUrgent ||
|
||||
if c := told.last(IDOf(gCat, gHk)); c == nil || c.State != occ.StateRaised || c.Severity != occ.SeverityUrgent ||
|
||||
!strings.Contains(c.Summary, "feat/g") {
|
||||
t.Fatalf("the operator was not told, urgent: %+v", c)
|
||||
}
|
||||
@@ -512,11 +513,11 @@ func TestAnApprovedGroupIsHandedWholeInItsOrderAndAStopIsSaid(t *testing.T) {
|
||||
id := IDOf(gCtl, gHc)
|
||||
raised := 0
|
||||
for _, c := range told.told {
|
||||
if c.Subject.ID == id && c.State == asks.StateRaised {
|
||||
if c.Subject.ID == id && c.State == occ.StateRaised {
|
||||
raised++
|
||||
}
|
||||
}
|
||||
if c := told.last(id); raised != 1 || c == nil || c.Severity != asks.SeverityUrgent || !strings.Contains(c.Summary, "feat/g") ||
|
||||
if c := told.last(id); raised != 1 || c == nil || c.Severity != occ.SeverityUrgent || !strings.Contains(c.Summary, "feat/g") ||
|
||||
!strings.Contains(c.Summary, "half merged (merged 1 of 2)") {
|
||||
t.Fatalf("raised %d times, last %+v; wanted once, urgent, naming the group half merged", raised, c)
|
||||
}
|
||||
@@ -533,7 +534,7 @@ func TestAnApprovedGroupIsHandedWholeInItsOrderAndAStopIsSaid(t *testing.T) {
|
||||
w.h.PullMerged(merged(gCtl, 1, gHc, gT1))
|
||||
bridgeMerged(w, gCtl, 1, gHc, gT1)
|
||||
w.settleAll()
|
||||
if c := told.last(id); c == nil || c.State != asks.StateCleared {
|
||||
if c := told.last(id); c == nil || c.State != occ.StateCleared {
|
||||
t.Fatalf("the group is whole and its condition is not cleared: %+v", c)
|
||||
}
|
||||
}},
|
||||
@@ -548,7 +549,7 @@ func TestAnApprovedGroupIsHandedWholeInItsOrderAndAStopIsSaid(t *testing.T) {
|
||||
}
|
||||
w.h.wantForge()
|
||||
w.settleAll()
|
||||
if c := told.last("novox/app@0123456789ab"); c == nil || c.State != asks.StateCleared || len(told.kept) != 0 {
|
||||
if c := told.last("novox/app@0123456789ab"); c == nil || c.State != occ.StateCleared || len(told.kept) != 0 {
|
||||
t.Fatalf("not cleared: %+v, kept %v", c, told.kept)
|
||||
}
|
||||
}},
|
||||
@@ -702,7 +703,7 @@ func TestAnApprovedGroupIsHandedWholeInItsOrderAndAStopIsSaid(t *testing.T) {
|
||||
w.h.React()
|
||||
}
|
||||
c := told.last(IDOf(gCat, gHk))
|
||||
if c == nil || c.State != asks.StateRaised || c.Severity != asks.SeverityUrgent {
|
||||
if c == nil || c.State != occ.StateRaised || c.Severity != occ.SeverityUrgent {
|
||||
t.Fatalf("at t0+%s with no event: %+v", w.h.Now().Sub(t0), c)
|
||||
}
|
||||
}},
|
||||
@@ -718,7 +719,7 @@ func TestAnApprovedGroupIsHandedWholeInItsOrderAndAStopIsSaid(t *testing.T) {
|
||||
w.settleAll()
|
||||
w.later(UncheckedAfter + time.Minute)
|
||||
w.settleAll()
|
||||
if c := told.last(id); c == nil || c.State != asks.StateRaised {
|
||||
if c := told.last(id); c == nil || c.State != occ.StateRaised {
|
||||
t.Fatalf("the unchecked head is not raised: %+v", c)
|
||||
}
|
||||
before := len(told.told)
|
||||
@@ -734,7 +735,7 @@ func TestAnApprovedGroupIsHandedWholeInItsOrderAndAStopIsSaid(t *testing.T) {
|
||||
w.forge.open = nil // it closes
|
||||
again.h.wantForge()
|
||||
again.settleAll()
|
||||
if c := told.last(id); c == nil || c.State != asks.StateCleared {
|
||||
if c := told.last(id); c == nil || c.State != occ.StateCleared {
|
||||
t.Fatalf("closed, and its condition is not cleared: %+v", c)
|
||||
}
|
||||
}},
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
|
||||
sdk "git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
@@ -29,19 +29,20 @@ func TestAReadyGroupWritesOneAskKeyWithTodaysWordsAndCallsNoRouter(t *testing.T)
|
||||
t.Fatalf("the key %q is not merge.<group>.<heads hash>: %q %q %v", key, kind, group, err)
|
||||
}
|
||||
ask := w.op.byKey[key]
|
||||
routed := w.op.ask(key)
|
||||
if ask.Group != "feat/g" || len(ask.Heads) != 2 {
|
||||
t.Fatalf("the ask value %+v does not name the group and both heads", ask)
|
||||
}
|
||||
if want, err := sdk.MergeAskKey("feat/g", ask.Heads); err != nil || want != key || !strings.HasSuffix(key, hash) {
|
||||
t.Fatalf("the heads %v give the key %q, the ask is under %q (%v)", ask.Heads, want, key, err)
|
||||
}
|
||||
if ask.Ask.ID != sdk.AskID(key) {
|
||||
t.Fatalf("the router's id of the ask is %q, wanted %q", ask.Ask.ID, sdk.AskID(key))
|
||||
if routed.ID != sdk.AskID(key) {
|
||||
t.Fatalf("the router's id of the ask is %q, wanted %q", routed.ID, sdk.AskID(key))
|
||||
}
|
||||
// Today's words (approval.go composeMergeAsk): the headline names the group, the details its members.
|
||||
if !strings.Contains(ask.Ask.Headline, "group feat") || !strings.Contains(ask.Ask.Details, "mesh-controller#1") ||
|
||||
!strings.Contains(ask.Ask.Details, "mesh-catalog#2") || len(ask.Ask.Options) != 2 || ask.Ask.Who != asks.Operator {
|
||||
t.Fatalf("the ask is not today's merge ask: %+v", ask.Ask)
|
||||
if !strings.Contains(routed.Headline, "group feat") || !strings.Contains(routed.Details, "mesh-controller#1") ||
|
||||
!strings.Contains(routed.Details, "mesh-catalog#2") || len(routed.Options) != 2 || routed.Who != occ.Operator {
|
||||
t.Fatalf("the ask is not today's merge ask: %+v", routed)
|
||||
}
|
||||
// A group's headline says a failed check first (issue 1166); a passed or a running one keeps the plain words.
|
||||
members := []*Delivery{{Repository: "novox/mesh-controller", Number: 1}, {Repository: "novox/mesh-catalog", Number: 2}}
|
||||
@@ -56,9 +57,9 @@ func TestAReadyGroupWritesOneAskKeyWithTodaysWordsAndCallsNoRouter(t *testing.T)
|
||||
}
|
||||
// The act the Approve binds is the one the teller checks and the controller verifies: this ask's router id, the option, and
|
||||
// the heads as `owner/repo@<first 12 characters>` to the whole commit.
|
||||
approve, _ := ask.Ask.Option(OptionApprove)
|
||||
approve, _ := occ.OptionOf(routed, OptionApprove)
|
||||
heads := map[string]string{IDOf(gCtl, gHc): gHc, IDOf(gCat, gHk): gHk}
|
||||
want, _ := asks.ActDigest(asks.Act{"verb": "mesh-delivery.merge", "ask": sdk.AskID(key), "option": "approve",
|
||||
want, _ := occ.ActDigest(occ.Act{"verb": "mesh-delivery.merge", "ask": sdk.AskID(key), "option": "approve",
|
||||
"heads": "novox/mesh-catalog@d2d2d2d2d2d2=" + gHk + ",novox/mesh-controller@c1c1c1c1c1c1=" + gHc})
|
||||
if approve.Binds != want {
|
||||
t.Fatalf("the Approve binds %s, wanted the act %v of %v", approve.Binds, mergeAct(sdk.AskID(key), "approve", heads), heads)
|
||||
|
||||
@@ -5,7 +5,8 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
|
||||
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
|
||||
sdk "git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
@@ -17,7 +18,7 @@ type fakeOperator struct {
|
||||
mu sync.Mutex
|
||||
now func() time.Time
|
||||
auto bool
|
||||
asked []asks.Ask
|
||||
asked []oc.Ask
|
||||
byKey map[string]sdk.Ask
|
||||
order []string
|
||||
seen map[string]bool
|
||||
@@ -37,7 +38,11 @@ func (o *fakeOperator) Ask(key string, a sdk.Ask) error {
|
||||
if o.refuse != nil {
|
||||
return o.refuse
|
||||
}
|
||||
if err := a.Ask.Check(o.now()); err != nil {
|
||||
ask, err := askOf(a)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := occ.Check(ask, o.now()); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := sdk.EncodeAsk(a); err != nil {
|
||||
@@ -46,7 +51,7 @@ func (o *fakeOperator) Ask(key string, a sdk.Ask) error {
|
||||
if o.state[key] == "open" && sdk.SameAsk(o.byKey[key], a) {
|
||||
return nil // the same ask is there, as kvOperator leaves it
|
||||
}
|
||||
o.asked = append(o.asked, a.Ask)
|
||||
o.asked = append(o.asked, ask)
|
||||
o.byKey[key] = a
|
||||
if _, known := o.seen[key]; !known {
|
||||
o.order = append(o.order, key)
|
||||
@@ -75,16 +80,20 @@ func (o *fakeOperator) Where(key string) (*sdk.Answer, error) {
|
||||
}
|
||||
|
||||
// ask is the router's ask at a key, as the gate composed it.
|
||||
func (o *fakeOperator) ask(key string) asks.Ask {
|
||||
func (o *fakeOperator) ask(key string) oc.Ask {
|
||||
if a, ok := o.byKey[key]; ok {
|
||||
return a.Ask
|
||||
ask, err := askOf(a)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return ask
|
||||
}
|
||||
panic("no ask " + key)
|
||||
}
|
||||
|
||||
func (o *fakeOperator) answerLocked(key, option string) {
|
||||
a := o.ask(key)
|
||||
opt, _ := a.Option(option)
|
||||
opt, _ := occ.OptionOf(a, option)
|
||||
o.state[key] = "answered"
|
||||
o.answers[key] = &sdk.Answer{Option: option, Warrant: fmt.Sprintf("%s@%s", a.ID, o.now().UTC().Format(time.RFC3339)),
|
||||
Words: "the operator, via telegram, chose " + opt.Label}
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
|
||||
sdk "git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
@@ -111,7 +111,7 @@ type ReadyRun struct {
|
||||
// waiting for an answer, Ask the ask as made (a warrant is held to it), Heads the heads it covers by delivery id (a
|
||||
// group's members together), AskedAt when and Asks how many were made for this head.
|
||||
AskID string `json:"ask_id,omitempty"`
|
||||
Ask *asks.Ask `json:"ask,omitempty"`
|
||||
Ask *oc.Ask `json:"ask,omitempty"`
|
||||
Heads map[string]string `json:"heads,omitempty"`
|
||||
AskedAt time.Time `json:"asked_at"`
|
||||
Asks int `json:"asks,omitempty"`
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
|
||||
sdk "git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
@@ -159,7 +159,7 @@ func TestAnApprovedPullRequestIsMergedThroughTheMergeTool(t *testing.T) {
|
||||
t.Fatalf("the ask says %q:\n%s", w, e)
|
||||
}
|
||||
}
|
||||
if err := asks.Glance(e); err != nil {
|
||||
if err := occ.Glance(e); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -6,7 +6,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
|
||||
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
|
||||
sdk "git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
@@ -97,7 +98,11 @@ func (h *Holder) Settle(ids []string, why, by string) (map[string]any, error) {
|
||||
asking = max(d.Settle.Asking, 1) + 1
|
||||
}
|
||||
}
|
||||
ask := sdk.Ask{Group: group, Heads: heads, Asking: asking, Ask: composeSettleAsk(sdk.AskingID(key, asking), covered, why, now)}
|
||||
ask, err := gateAsk(group, heads, asking, composeSettleAsk(sdk.AskingID(key, asking), covered, why, now))
|
||||
if err != nil {
|
||||
h.mu.Unlock()
|
||||
return nil, fmt.Errorf("the settle ask is not one the operator can be asked: %w", err)
|
||||
}
|
||||
earlier := map[string]bool{}
|
||||
for _, d := range covered {
|
||||
if d.Settle != nil && d.Settle.Answered == nil && d.Settle.Key != key {
|
||||
@@ -132,14 +137,14 @@ func (h *Holder) Settle(ids []string, why, by string) (map[string]any, error) {
|
||||
}
|
||||
|
||||
// settleAct is what a settle ask's answer binds: its verb (mesh-delivery.settle), this ask, the answer and the heads.
|
||||
func settleAct(group, ask, option string, heads map[string]string) asks.Act {
|
||||
return asks.Act{"verb": sdk.AskVerb(group), "ask": ask, "option": option, "heads": headsString(heads)}
|
||||
func settleAct(group, ask, option string, heads map[string]string) occ.Act {
|
||||
return occ.Act{"verb": sdk.AskVerb(group), "ask": ask, "option": option, "heads": headsString(heads)}
|
||||
}
|
||||
|
||||
// composeSettleAsk is the operator's ask to settle these held deliveries.
|
||||
func composeSettleAsk(id string, ds []*Delivery, why string, now time.Time) asks.Ask {
|
||||
var a asks.Ask
|
||||
a.ID, a.Who, a.Expires = id, asks.Operator, now.Add(AskLasts)
|
||||
func composeSettleAsk(id string, ds []*Delivery, why string, now time.Time) oc.Ask {
|
||||
var a oc.Ask
|
||||
a.ID, a.Who, a.Expires = id, occ.Operator, occ.FormatTime(now.Add(AskLasts))
|
||||
a.OnExpiry = "they stay held; settle asks again"
|
||||
a.About = "delivery.settle"
|
||||
var pulls, lines []string
|
||||
@@ -150,11 +155,11 @@ func composeSettleAsk(id string, ds []*Delivery, why string, now time.Time) asks
|
||||
lines = append(lines, phone(fmt.Sprintf("%s %q merged at %s without a passing check", pullSaid(d), d.Title,
|
||||
shortOf(d.MergedAs, 12)), 400))
|
||||
}
|
||||
a.Headline = phone(fmt.Sprintf("Settle %d merges made without a passing check", len(ds)), asks.HeadlineLength)
|
||||
a.Headline = phone(fmt.Sprintf("Settle %d merges made without a passing check", len(ds)), occ.HeadlineLength)
|
||||
a.Explanation = strings.Join([]string{
|
||||
phone(fmt.Sprintf("%s reached main without a passing check and are held. Settle ends the hold: each reads merged, and on the machines once every machine runs it. Leave keeps them held.",
|
||||
strings.Join(pulls, ", ")), asks.SummaryLength),
|
||||
phone("Why: "+why, asks.PointLength),
|
||||
strings.Join(pulls, ", ")), occ.SummaryLength),
|
||||
phone("Why: "+why, occ.PointLength),
|
||||
}, "\n")
|
||||
a.Details = strings.Join(lines, "\n")
|
||||
group := sdk.SettleGroup(func() []string {
|
||||
@@ -168,8 +173,8 @@ func composeSettleAsk(id string, ds []*Delivery, why string, now time.Time) asks
|
||||
{OptionSettle, "Settle", "each delivery named ends its hold and reads merged"},
|
||||
{OptionLeave, "Leave", "they stay held"},
|
||||
} {
|
||||
binds, _ := asks.ActDigest(settleAct(group, id, o.id, heads))
|
||||
a.Options = append(a.Options, asks.Option{ID: o.id, Label: o.label, Does: o.does, Level: asks.Approve, Binds: binds})
|
||||
binds, _ := occ.ActDigest(settleAct(group, id, o.id, heads))
|
||||
a.Options = append(a.Options, oc.Option{ID: o.id, Label: o.label, Does: o.does, Level: occ.Approve, Binds: binds})
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
|
||||
sdk "git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
@@ -57,7 +57,7 @@ func TestSettleAsksTheOperatorOnceAndMovesOnlyOnTheAnswer(t *testing.T) {
|
||||
heads := map[string]string{a.ID: a.Commit, b.ID: b.Commit}
|
||||
group := sdk.SettleGroup([]string{a.ID, b.ID})
|
||||
for _, o := range ask.Options {
|
||||
if want, _ := asks.ActDigest(settleAct(group, ask.ID, o.ID, heads)); o.Binds != want {
|
||||
if want, _ := occ.ActDigest(settleAct(group, ask.ID, o.ID, heads)); o.Binds != want {
|
||||
t.Errorf("the option %s binds %s, not mesh-delivery.settle of these heads", o.ID, o.Binds)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ module mesh-delivery-gate
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-contracts/go/seats/operatorchannel v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929
|
||||
github.com/nats-io/nats-server/v2 v2.11.17
|
||||
github.com/nats-io/nats.go v1.54.0
|
||||
|
||||
@@ -24,7 +24,8 @@ import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
|
||||
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
|
||||
"git.novox.be/novox/mesh-sdk/go/handacts"
|
||||
)
|
||||
|
||||
@@ -154,7 +155,7 @@ func (l *Log) record(act Act) error {
|
||||
// routerRecord is what warranted reads of the router's record of an ask.
|
||||
type routerRecord struct {
|
||||
State string `json:"state"`
|
||||
Warrant *asks.Warrant `json:"warrant"`
|
||||
Warrant *oc.Warrant `json:"warrant"`
|
||||
Ask map[string]any `json:"ask"`
|
||||
}
|
||||
|
||||
@@ -175,7 +176,7 @@ func (l *Log) Warranted(caller, asker, ask string) (Recorded, error) {
|
||||
return Recorded{}, fmt.Errorf("who asks is the caller the runtime proves, and it proved none (%q): nothing was recorded", caller)
|
||||
case !askerModule.MatchString(asker):
|
||||
return Recorded{}, fmt.Errorf("%q is not a module's name", asker)
|
||||
case !asks.UsableID(ask):
|
||||
case !occ.UsableID(ask):
|
||||
return Recorded{}, fmt.Errorf("%q is not an ask's id", ask)
|
||||
}
|
||||
key := asker + "." + ask
|
||||
@@ -206,16 +207,20 @@ func (l *Log) Warranted(caller, asker, ask string) (Recorded, error) {
|
||||
return Recorded{}, fmt.Errorf("the router holds no closed record of %s's ask %s. Nothing was recorded", asker, ask)
|
||||
case w.Asker != asker || w.Ask != ask:
|
||||
return Recorded{}, fmt.Errorf("the router's record is for %s's ask %s. Nothing was recorded", w.Asker, w.Ask)
|
||||
case w.Outcome != asks.OutcomeChosen || w.By == nil:
|
||||
case w.Outcome != occ.OutcomeChosen || w.By == nil:
|
||||
return Recorded{}, fmt.Errorf("%s's ask %s ended %s: no person chose, so there is no warrant to record. Nothing was "+
|
||||
"recorded", asker, ask, w.Outcome)
|
||||
case w.AskDigest == "":
|
||||
return Recorded{}, fmt.Errorf("the router's warrant for %s's ask %s names no ask digest. Nothing was recorded", asker, ask)
|
||||
}
|
||||
warranted, err := occ.Time(w.At)
|
||||
if err != nil {
|
||||
return Recorded{}, fmt.Errorf("the router's record of %s's ask %s is not readable: %w", asker, ask, err)
|
||||
}
|
||||
at := l.Now().UTC()
|
||||
act := Act{ID: "warrant-" + asker + "-" + ask, At: w.At.UTC(), Verb: VerbWarrant,
|
||||
act := Act{ID: "warrant-" + asker + "-" + ask, At: warranted.UTC(), Verb: VerbWarrant,
|
||||
Args: []string{fmt.Sprintf("the operator chose %s on %s's ask %s", w.Label, asker, ask)},
|
||||
Why: fmt.Sprintf("%s (ask %s of %s)", w.Says(), ask, asker),
|
||||
Why: fmt.Sprintf("%s (ask %s of %s)", occ.Says(*w), ask, asker),
|
||||
By: fmt.Sprintf("the %s, as %s identity %s", w.By.Who, w.By.Kind, w.By.Identity),
|
||||
Cause: CauseOperatorAnswer, Via: via(*w), Ask: ask, Proofs: w.Proofs,
|
||||
RequestedBy: asker + ", recorded at the word of " + caller,
|
||||
@@ -250,7 +255,7 @@ func (l *Log) already(key string, raw []byte) (Recorded, bool, error) {
|
||||
return Recorded{Act: act, Said: "already recorded as " + act.ID + ": " + act.Why}, true, nil
|
||||
}
|
||||
|
||||
func via(w asks.Warrant) string {
|
||||
func via(w oc.Warrant) string {
|
||||
if w.By == nil {
|
||||
return w.Channel
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ module mesh-hand-acts
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-contracts/go/seats/operatorchannel v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128
|
||||
github.com/nats-io/nats.go v1.54.0
|
||||
)
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
|
||||
)
|
||||
|
||||
// novox/hq issue 639: the operator would not read the ask for ADR 0343 — thirty plain lines, every clause one per
|
||||
@@ -44,13 +44,13 @@ func TestADecisionAskReadsAtAGlance(t *testing.T) {
|
||||
r := adr0343()
|
||||
at := time.Date(2026, 10, 13, 14, 5, 0, 0, time.UTC)
|
||||
a := composeAsk(r, &OperatorAsk{ID: "kb-decision-343-0d1e2f3a", At: at, Version: 12, Form: AskGlance})
|
||||
if err := a.Check(at); err != nil {
|
||||
if err := occ.Check(a, at); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a.Headline != "Your agents' memory follows you to every machine" {
|
||||
t.Errorf("the headline is not what it changes for the operator: %q", a.Headline)
|
||||
}
|
||||
if err := asks.Glance(a.Explanation); err != nil {
|
||||
if err := occ.Glance(a.Explanation); err != nil {
|
||||
t.Errorf("%v:\n%s", err, a.Explanation)
|
||||
}
|
||||
if n := strings.Count(a.Explanation, "\n• "); n != 3 || !strings.HasPrefix(a.Explanation, "What an agent learns") {
|
||||
@@ -69,13 +69,13 @@ func TestADecisionAskReadsAtAGlance(t *testing.T) {
|
||||
}
|
||||
// The ask made before the brief is composed again as it was, so its warrant still holds after the change.
|
||||
old := composeAsk(r, &OperatorAsk{ID: "kb-decision-343-0d1e2f3a", At: at, Version: 12})
|
||||
if !strings.Contains(old.Explanation, "Its 13 clause(s):") || old.Headline != "Accept ADR 0343?" || old.Details != "" || old.Digest() == a.Digest() {
|
||||
if !strings.Contains(old.Explanation, "Its 13 clause(s):") || old.Headline != "Accept ADR 0343?" || old.Details != "" || occ.Digest(old) == occ.Digest(a) {
|
||||
t.Errorf("an ask made before the brief is not composed as it was asked:\n%s", old.Explanation)
|
||||
}
|
||||
// Without a brief (a record proposed before the rule), the glance is the title, and no clause.
|
||||
r.Brief = nil
|
||||
a = composeAsk(r, &OperatorAsk{ID: "kb-decision-343-0d1e2f3a", At: at, Version: 12, Form: AskGlance})
|
||||
if a.Headline != "Approve ADR 0343?" || asks.Glance(a.Explanation) != nil || strings.Contains(a.Explanation, "Only the memory folders") {
|
||||
if a.Headline != "Approve ADR 0343?" || occ.Glance(a.Explanation) != nil || strings.Contains(a.Explanation, "Only the memory folders") {
|
||||
t.Errorf("without a brief: %q\n%s", a.Headline, a.Explanation)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,8 +17,8 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
)
|
||||
|
||||
// Unproven is who acted, as recorded, where the runtime proves no caller (hq issue 365): the SDK's word. The caller
|
||||
@@ -816,7 +816,7 @@ func (k *KB) WithdrawRecord(caller string, key Key, why string) (*Withdrawn, err
|
||||
k.emit(EventChanged, r, caller, full, e.Versions+1)
|
||||
if waiting && k.Asks != nil {
|
||||
id := cur.Ask.ID
|
||||
if err := k.Asks.Publish(asks.CancelSubject(Module), map[string]string{"id": id}, "cancel."+id); err != nil {
|
||||
if err := k.Asks.Publish(oc.AcceptSubjectCancel(Module), map[string]string{"id": id}, "cancel."+id); err != nil {
|
||||
k.logf("[mesh-knowledge] the ask %s for %s is taken back here, and the router could not be told (%v): it expires there, "+
|
||||
"and no answer to it is acted on", id, key, err)
|
||||
}
|
||||
|
||||
@@ -22,8 +22,9 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
|
||||
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
)
|
||||
|
||||
const bundleVar = "MESH_KNOWLEDGE_TEST_BUNDLE"
|
||||
@@ -326,7 +327,7 @@ func (j *judge) judge(question string, schema map[string]any, model string) (jso
|
||||
// the cancels taken back on its subject.
|
||||
type router struct {
|
||||
mu sync.Mutex
|
||||
asked []asks.Ask
|
||||
asked []oc.Ask
|
||||
records map[string]routerRecord
|
||||
cancelled []string
|
||||
}
|
||||
@@ -337,13 +338,13 @@ func (r *router) Publish(subject string, body any, id string) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
switch subject {
|
||||
case asks.AskSubject(Module):
|
||||
a := body.(asks.Ask)
|
||||
case oc.AcceptSubjectAsk(Module):
|
||||
a := body.(oc.Ask)
|
||||
r.asked = append(r.asked, a)
|
||||
cp := a
|
||||
r.records[a.ID] = routerRecord{State: "open", Ask: &cp}
|
||||
return nil
|
||||
case asks.CancelSubject(Module):
|
||||
case oc.AcceptSubjectCancel(Module):
|
||||
m := body.(map[string]string)
|
||||
r.cancelled = append(r.cancelled, m["id"])
|
||||
return nil
|
||||
@@ -363,14 +364,14 @@ func (r *router) Record(id string) (*stdio.StateEntry, error) {
|
||||
}
|
||||
|
||||
// answer closes an ask as the router does when the operator taps an option on a channel that proves who answered.
|
||||
func (r *router) answer(id, option string, at time.Time) asks.Warrant {
|
||||
func (r *router) answer(id, option string, at time.Time) oc.Warrant {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
rec := r.records[id]
|
||||
o, _ := rec.Ask.Option(option)
|
||||
w := asks.Warrant{Ask: id, Asker: Module, About: rec.Ask.About, Outcome: asks.OutcomeChosen, Option: o.ID, Label: o.Label,
|
||||
Level: o.Level, By: &asks.Person{Who: asks.Operator, Kind: "telegram", Identity: "1", Verified: "user id verified"},
|
||||
Channel: "telegram", At: at, AskDigest: rec.Ask.Digest()}
|
||||
o, _ := occ.OptionOf(*rec.Ask, option)
|
||||
w := oc.Warrant{Ask: id, Asker: Module, About: rec.Ask.About, Outcome: occ.OutcomeChosen, Option: o.ID, Label: o.Label,
|
||||
Level: o.Level, By: &oc.Person{Who: occ.Operator, Kind: "telegram", Identity: "1", Verified: "user id verified"},
|
||||
Channel: "telegram", At: occ.FormatTime(at), AskDigest: occ.Digest(*rec.Ask)}
|
||||
rec.State, rec.Warrant = "closed", &w
|
||||
r.records[id] = rec
|
||||
return w
|
||||
|
||||
@@ -29,8 +29,8 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
)
|
||||
|
||||
// Seat is the role this module holds, once for the mesh.
|
||||
@@ -122,8 +122,8 @@ func callerOf(args map[string]any) string { return stdio.CallerOf(args) }
|
||||
// hear subscribes to the router's word on this module's asks, asked again until the runtime takes it.
|
||||
func hear(k *KB) {
|
||||
for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) {
|
||||
err := stdio.Subscribe(asks.Seat+".decided."+Module, func(e stdio.Envelope) error {
|
||||
var w asks.Warrant
|
||||
err := stdio.Subscribe(oc.Seat+".decided."+Module, func(e stdio.Envelope) error {
|
||||
var w oc.Warrant
|
||||
if json.Unmarshal(e.Body, &w) == nil {
|
||||
k.HeardEnd(w)
|
||||
}
|
||||
|
||||
@@ -20,8 +20,9 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
|
||||
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
)
|
||||
|
||||
// Module is this module's name: the one the bus grants its asks under.
|
||||
@@ -34,7 +35,7 @@ const (
|
||||
)
|
||||
|
||||
// AskLasts is how long the operator has to answer: a day less a margin, under the router's bound for an approval.
|
||||
const AskLasts = asks.ApproveLasts - 10*time.Minute
|
||||
const AskLasts = occ.ApproveLasts - 10*time.Minute
|
||||
|
||||
// The states of an operator's ask, as the record keeps them.
|
||||
const (
|
||||
@@ -58,8 +59,8 @@ func (sdkAskBus) Publish(subject string, body any, id string) error {
|
||||
func (sdkAskBus) Record(id string) (*stdio.StateEntry, error) { return stdio.SeatRecord("", id) }
|
||||
|
||||
// act is what an answer binds: this record's version, by its digest, and the answer.
|
||||
func act(r *Record, a *OperatorAsk, option string) asks.Act {
|
||||
return asks.Act{"verb": "knowledge-base.accept", "option": option, "ask": a.ID, "kind": r.Kind,
|
||||
func act(r *Record, a *OperatorAsk, option string) occ.Act {
|
||||
return occ.Act{"verb": "knowledge-base.accept", "option": option, "ask": a.ID, "kind": r.Kind,
|
||||
"number": strconv.Itoa(r.Number), "version": strconv.Itoa(a.Version), "digest": r.Digest()}
|
||||
}
|
||||
|
||||
@@ -90,7 +91,7 @@ const AskGlance = 1
|
||||
// composeAsk is the ask for a record, composed from the record and its ask alone: asked once, and composed
|
||||
// again to hold the warrant to it — in the form it was asked in, so an ask made before a form changed is still
|
||||
// held to the words the operator was shown.
|
||||
func composeAsk(r *Record, a *OperatorAsk) asks.Ask {
|
||||
func composeAsk(r *Record, a *OperatorAsk) oc.Ask {
|
||||
if a.Form == AskGlance {
|
||||
return glanceAsk(r, a)
|
||||
}
|
||||
@@ -101,10 +102,10 @@ func composeAsk(r *Record, a *OperatorAsk) asks.Ask {
|
||||
// points; who proposed it, its review, its fingerprint and how to read every clause are its Details, which the
|
||||
// router notes after the answers. A record without a brief (one proposed before the rule) is asked with its
|
||||
// title as its summary. The clauses are never listed.
|
||||
func glanceAsk(r *Record, a *OperatorAsk) asks.Ask {
|
||||
func glanceAsk(r *Record, a *OperatorAsk) oc.Ask {
|
||||
name := r.Key().String()
|
||||
headline, explanation := "Approve "+name+"?", name+" needs your approval before it holds."
|
||||
if t := strings.TrimSuffix(strings.TrimSpace(r.Title), "."); shown(t) && len([]rune(t)) < asks.SummaryLength {
|
||||
if t := strings.TrimSuffix(strings.TrimSpace(r.Title), "."); shown(t) && len([]rune(t)) < occ.SummaryLength {
|
||||
explanation = t + "."
|
||||
}
|
||||
if r.Brief != nil && len(briefProblems(r.Brief)) == 0 {
|
||||
@@ -120,12 +121,12 @@ func glanceAsk(r *Record, a *OperatorAsk) asks.Ask {
|
||||
}
|
||||
details := fmt.Sprintf("%s, proposed by %s; the knowledge base's own review approved it. Fingerprint %s.\n%s",
|
||||
name, author, fingerprint(r.Digest()), clauses)
|
||||
return withOptions(r, a, asks.Ask{ID: a.ID, Headline: headline, Explanation: explanation, Details: details})
|
||||
return withOptions(r, a, oc.Ask{ID: a.ID, Headline: headline, Explanation: explanation, Details: details})
|
||||
}
|
||||
|
||||
// listedAsk is the ask as it was composed before the brief: every clause's lead listed. Composed only to hold
|
||||
// the warrant of an ask made then.
|
||||
func listedAsk(r *Record, a *OperatorAsk) asks.Ask {
|
||||
func listedAsk(r *Record, a *OperatorAsk) oc.Ask {
|
||||
name := r.Key().String()
|
||||
headline := "Accept " + name + "?"
|
||||
var b strings.Builder
|
||||
@@ -150,21 +151,21 @@ func listedAsk(r *Record, a *OperatorAsk) asks.Ask {
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(&b, "\n\nRead it whole with the knowledge base's get, number %d; its fingerprint is %s.", r.Number, fingerprint(r.Digest()))
|
||||
return withOptions(r, a, asks.Ask{ID: a.ID, Headline: headline, Explanation: b.String()})
|
||||
return withOptions(r, a, oc.Ask{ID: a.ID, Headline: headline, Explanation: b.String()})
|
||||
}
|
||||
|
||||
// withOptions is an ask with what every form of it carries: who answers, its expiry, what it is about, and its
|
||||
// two options, each binding its act.
|
||||
func withOptions(r *Record, a *OperatorAsk, out asks.Ask) asks.Ask {
|
||||
out.Who, out.Expires = asks.Operator, a.At.Add(AskLasts)
|
||||
func withOptions(r *Record, a *OperatorAsk, out oc.Ask) oc.Ask {
|
||||
out.Who, out.Expires = occ.Operator, occ.FormatTime(a.At.Add(AskLasts))
|
||||
out.OnExpiry = "the record stays proposed; nothing of it is in force"
|
||||
out.About = "knowledge." + r.Kind + "." + strconv.Itoa(r.Number)
|
||||
for _, o := range []struct{ id, label, does string }{
|
||||
{OptionApprove, "Approve", "the record is accepted and its clauses are in force"},
|
||||
{OptionDecline, "Decline", "the record stays proposed; nothing of it is in force"},
|
||||
} {
|
||||
binds, _ := asks.ActDigest(act(r, a, o.id))
|
||||
out.Options = append(out.Options, asks.Option{ID: o.id, Label: o.label, Does: o.does, Level: asks.Approve, Binds: binds})
|
||||
binds, _ := occ.ActDigest(act(r, a, o.id))
|
||||
out.Options = append(out.Options, oc.Option{ID: o.id, Label: o.label, Does: o.does, Level: occ.Approve, Binds: binds})
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -204,7 +205,7 @@ func (k *KB) askOperator(ctx context.Context, held *Entry, w *Record, by string,
|
||||
}
|
||||
oa := &OperatorAsk{ID: newAskID(w), At: k.now(), Version: version, Said: AskWaiting, Form: AskGlance}
|
||||
ask := composeAsk(w, oa)
|
||||
if err := ask.Check(k.now()); err != nil {
|
||||
if err := occ.Check(ask, k.now()); err != nil {
|
||||
return unasked(fmt.Sprintf("the ask could not be made (%v)", err))
|
||||
}
|
||||
w.Ask = oa
|
||||
@@ -213,7 +214,7 @@ func (k *KB) askOperator(ctx context.Context, held *Entry, w *Record, by string,
|
||||
return "", fmt.Errorf("%s: the review could not be kept (%v); the operator was not asked", w.Key(), err)
|
||||
}
|
||||
k.emit(EventReviewed, w, by, "approved; the operator is asked", held.Versions+1)
|
||||
if err := k.Asks.Publish(asks.AskSubject(Module), ask, ask.ID); err != nil {
|
||||
if err := k.Asks.Publish(oc.AcceptSubjectAsk(Module), ask, ask.ID); err != nil {
|
||||
k.logf("[mesh-knowledge] the ask for %s could not be published: %v", w.Key(), err)
|
||||
return unasked(fmt.Sprintf("the operator could not be asked (%v)", err))
|
||||
}
|
||||
@@ -224,9 +225,9 @@ func (k *KB) askOperator(ctx context.Context, held *Entry, w *Record, by string,
|
||||
type routerRecord struct {
|
||||
State string `json:"state"`
|
||||
// Held is why no channel can carry the ask yet, while its state is held (ADR 0397).
|
||||
Held string `json:"held"`
|
||||
Ask *asks.Ask `json:"ask"`
|
||||
Warrant *asks.Warrant `json:"warrant"`
|
||||
Held string `json:"held"`
|
||||
Ask *oc.Ask `json:"ask"`
|
||||
Warrant *oc.Warrant `json:"warrant"`
|
||||
}
|
||||
|
||||
// Reconcile decides every record waiting for the operator whose ask the router has closed: accepted on a warrant
|
||||
@@ -319,10 +320,10 @@ func (k *KB) endAsk(ctx context.Context, e *Entry, w *Record, why string) string
|
||||
}
|
||||
|
||||
// decide holds one closed ask's warrant to the record, and accepts the record when it is the operator's Approve.
|
||||
func (k *KB) decide(ctx context.Context, e *Entry, w *Record, warrant asks.Warrant) string {
|
||||
func (k *KB) decide(ctx context.Context, e *Entry, w *Record, warrant oc.Warrant) string {
|
||||
w = w.Clone()
|
||||
w.Ask.Held = "" // an ask that ended is held no longer
|
||||
if warrant.Outcome != asks.OutcomeChosen {
|
||||
if warrant.Outcome != occ.OutcomeChosen {
|
||||
why := "ended " + string(warrant.Outcome)
|
||||
if warrant.Words != "" {
|
||||
why += " (" + warrant.Words + ")"
|
||||
@@ -330,9 +331,9 @@ func (k *KB) decide(ctx context.Context, e *Entry, w *Record, warrant asks.Warra
|
||||
return k.endAsk(ctx, e, w, why)
|
||||
}
|
||||
ask := composeAsk(w, w.Ask)
|
||||
option, err := warrant.For(Module, ask)
|
||||
option, err := occ.For(warrant, Module, ask)
|
||||
if err == nil {
|
||||
err = option.Performs(act(w, w.Ask, option.ID))
|
||||
err = occ.Performs(option, act(w, w.Ask, option.ID))
|
||||
}
|
||||
if err != nil {
|
||||
k.logf("[mesh-knowledge] REFUSED the answer to the ask for %s: %v", w.Key(), err)
|
||||
@@ -341,15 +342,15 @@ func (k *KB) decide(ctx context.Context, e *Entry, w *Record, warrant asks.Warra
|
||||
switch option.ID {
|
||||
case OptionApprove:
|
||||
w = w.Clone()
|
||||
w.Ask.Said, w.Ask.Warrant = AskApproved, warrant.Says()
|
||||
r, err := k.accept(ctx, e, w, "the operator", "accepted on the operator's Approve after the knowledge base's review", "warrant: "+warrant.Says())
|
||||
w.Ask.Said, w.Ask.Warrant = AskApproved, occ.Says(warrant)
|
||||
r, err := k.accept(ctx, e, w, "the operator", "accepted on the operator's Approve after the knowledge base's review", "warrant: "+occ.Says(warrant))
|
||||
if err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
return fmt.Sprintf("%s: %s on the operator's warrant (%s)", r.Key(), r.Status, warrant.Says())
|
||||
return fmt.Sprintf("%s: %s on the operator's warrant (%s)", r.Key(), r.Status, occ.Says(warrant))
|
||||
case OptionDecline:
|
||||
w = w.Clone()
|
||||
w.Ask.Warrant = warrant.Says()
|
||||
w.Ask.Warrant = occ.Says(warrant)
|
||||
return k.endAsk(ctx, e, w, "declined by the operator")
|
||||
}
|
||||
return k.endAsk(ctx, e, w, "was answered with "+option.ID+", which it does not offer")
|
||||
@@ -357,8 +358,8 @@ func (k *KB) decide(ctx context.Context, e *Entry, w *Record, warrant asks.Warra
|
||||
|
||||
// HeardEnd takes the router's word that one of this module's asks ended without a choice: nothing is accepted on
|
||||
// such a word, so it needs no proof beyond its subject. A choice is only ever taken from the router's record.
|
||||
func (k *KB) HeardEnd(w asks.Warrant) {
|
||||
if w.Asker != Module || w.Outcome == asks.OutcomeChosen {
|
||||
func (k *KB) HeardEnd(w oc.Warrant) {
|
||||
if w.Asker != Module || w.Outcome == occ.OutcomeChosen {
|
||||
if _, err := k.Reconcile(); err != nil {
|
||||
k.logf("[mesh-knowledge] reading the router's answers: %v", err)
|
||||
}
|
||||
|
||||
@@ -20,7 +20,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
|
||||
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
|
||||
)
|
||||
|
||||
// RetractAsked is the status of a retraction asked and not yet answered.
|
||||
@@ -127,7 +128,7 @@ func (k *KB) Retract(caller string, key Key, clauses []int, why string) (*Retrac
|
||||
x := &Retraction{Clauses: sorted, Why: why, By: caller, At: at, Title: cur.Title, Leads: leads,
|
||||
Ask: &OperatorAsk{ID: newRetractAskID(cur), At: at, Version: e.CurrentVersion, Said: AskWaiting, Form: AskGlance}}
|
||||
ask := composeRetractAsk(cur, x)
|
||||
if err := ask.Check(at); err != nil {
|
||||
if err := occ.Check(ask, at); err != nil {
|
||||
return nil, fmt.Errorf("the ask could not be made (%v); nothing was asked", err)
|
||||
}
|
||||
r := cur.Clone()
|
||||
@@ -136,7 +137,7 @@ func (k *KB) Retract(caller string, key Key, clauses []int, why string) (*Retrac
|
||||
if err := k.Store.Save(ctx, Version{By: caller, At: at, Why: what, Record: r}, true, false, e.Revision); err != nil {
|
||||
return nil, fmt.Errorf("%s could not be written (%v); nothing was asked", key, err)
|
||||
}
|
||||
if err := k.Asks.Publish(asks.AskSubject(Module), ask, ask.ID); err != nil {
|
||||
if err := k.Asks.Publish(oc.AcceptSubjectAsk(Module), ask, ask.ID); err != nil {
|
||||
// Taken back: the record says it was not asked, and nothing waits on it.
|
||||
k.logf("[mesh-knowledge] the retraction of %s could not be asked: %v", key, err)
|
||||
if e2, gerr := k.Store.Get(ctx, key); gerr == nil && e2 != nil {
|
||||
@@ -176,7 +177,7 @@ func (k *KB) WithdrawRetraction(caller string, key Key, why string) (*Withdrawn,
|
||||
return nil, err
|
||||
}
|
||||
if k.Asks != nil {
|
||||
if err := k.Asks.Publish(asks.CancelSubject(Module), map[string]string{"id": id}, "cancel."+id); err != nil {
|
||||
if err := k.Asks.Publish(oc.AcceptSubjectCancel(Module), map[string]string{"id": id}, "cancel."+id); err != nil {
|
||||
k.logf("[mesh-knowledge] the ask %s for %s is taken back here, and the router could not be told (%v): it expires there, "+
|
||||
"and no answer to it is acted on", id, key, err)
|
||||
}
|
||||
@@ -201,18 +202,18 @@ func newRetractAskID(r *Record) string {
|
||||
}
|
||||
|
||||
// retractAct is what an answer to a retraction binds: this record, the clauses, why, who asked, and the answer.
|
||||
func retractAct(r *Record, x *Retraction, option string) asks.Act {
|
||||
return asks.Act{"verb": "knowledge-base.retract", "option": option, "ask": x.Ask.ID, "kind": r.Kind,
|
||||
func retractAct(r *Record, x *Retraction, option string) occ.Act {
|
||||
return occ.Act{"verb": "knowledge-base.retract", "option": option, "ask": x.Ask.ID, "kind": r.Kind,
|
||||
"number": strconv.Itoa(r.Number), "clauses": clauseList(x.Clauses), "why": x.Why, "by": x.By}
|
||||
}
|
||||
|
||||
// composeRetractAsk is the ask for a retraction, composed from the retraction alone, so it is the same when it is
|
||||
// composed again to hold the warrant to it.
|
||||
func composeRetractAsk(r *Record, x *Retraction) asks.Ask {
|
||||
func composeRetractAsk(r *Record, x *Retraction) oc.Ask {
|
||||
name := r.Key().String()
|
||||
headline := "Retract " + name + "?"
|
||||
if !x.Whole() {
|
||||
if h := "Retract " + name + " " + clauseList(x.Clauses) + "?"; len([]rune(h)) <= asks.HeadlineLength {
|
||||
if h := "Retract " + name + " " + clauseList(x.Clauses) + "?"; len([]rune(h)) <= occ.HeadlineLength {
|
||||
headline = h
|
||||
} else {
|
||||
headline = fmt.Sprintf("Retract %d clauses of %s?", len(x.Clauses), name)
|
||||
@@ -225,12 +226,12 @@ func composeRetractAsk(r *Record, x *Retraction) asks.Ask {
|
||||
summary := what + " would no longer hold; nothing is deleted, its versions stay in history."
|
||||
var points []string
|
||||
if shown(x.Why) {
|
||||
points = append(points, "Why: "+clip(x.Why, asks.PointLength-6))
|
||||
points = append(points, "Why: "+clip(x.Why, occ.PointLength-6))
|
||||
} else {
|
||||
points = append(points, "Why: read it with the knowledge base's get.")
|
||||
}
|
||||
if t := strings.TrimSuffix(strings.TrimSpace(x.Title), "."); shown(t) {
|
||||
points = append(points, "Record: "+clip(t, asks.PointLength-9))
|
||||
points = append(points, "Record: "+clip(t, occ.PointLength-9))
|
||||
}
|
||||
if !x.Whole() {
|
||||
var leads []string
|
||||
@@ -240,7 +241,7 @@ func composeRetractAsk(r *Record, x *Retraction) asks.Ask {
|
||||
}
|
||||
}
|
||||
if len(leads) > 0 {
|
||||
points = append(points, clip("Clauses: "+strings.Join(leads, "; "), asks.PointLength))
|
||||
points = append(points, clip("Clauses: "+strings.Join(leads, "; "), occ.PointLength))
|
||||
}
|
||||
}
|
||||
explanation := summary
|
||||
@@ -249,15 +250,15 @@ func composeRetractAsk(r *Record, x *Retraction) asks.Ask {
|
||||
}
|
||||
details := fmt.Sprintf("%s, retraction of %s asked by %s: %s\nRead it with the knowledge base's get, number %d.",
|
||||
name, x.What(), x.By, x.Why, r.Number)
|
||||
out := asks.Ask{ID: x.Ask.ID, Headline: headline, Explanation: explanation, Details: details,
|
||||
Who: asks.Operator, Expires: x.Ask.At.Add(AskLasts), OnExpiry: "nothing is retracted; the record holds as it did",
|
||||
out := oc.Ask{ID: x.Ask.ID, Headline: headline, Explanation: explanation, Details: details,
|
||||
Who: occ.Operator, Expires: occ.FormatTime(x.Ask.At.Add(AskLasts)), OnExpiry: "nothing is retracted; the record holds as it did",
|
||||
About: "knowledge." + r.Kind + "." + strconv.Itoa(r.Number)}
|
||||
for _, o := range []struct{ id, label, does string }{
|
||||
{OptionApprove, "Approve", "it is retracted and no longer holds"},
|
||||
{OptionDecline, "Decline", "nothing is retracted; it holds as it did"},
|
||||
} {
|
||||
binds, _ := asks.ActDigest(retractAct(r, x, o.id))
|
||||
out.Options = append(out.Options, asks.Option{ID: o.id, Label: o.label, Does: o.does, Level: asks.Approve, Binds: binds})
|
||||
binds, _ := occ.ActDigest(retractAct(r, x, o.id))
|
||||
out.Options = append(out.Options, oc.Option{ID: o.id, Label: o.label, Does: o.does, Level: occ.Approve, Binds: binds})
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -310,30 +311,30 @@ func (k *KB) endRetractionSaid(ctx context.Context, e *Entry, by, why string) st
|
||||
}
|
||||
|
||||
// decideRetraction holds one closed ask's warrant to the retraction, and retracts on the operator's Approve.
|
||||
func (k *KB) decideRetraction(ctx context.Context, e *Entry, warrant asks.Warrant) string {
|
||||
func (k *KB) decideRetraction(ctx context.Context, e *Entry, warrant oc.Warrant) string {
|
||||
cur := e.Current
|
||||
x := cur.Retracting
|
||||
if warrant.Outcome != asks.OutcomeChosen {
|
||||
if warrant.Outcome != occ.OutcomeChosen {
|
||||
why := "retraction not made: the operator's ask ended " + string(warrant.Outcome)
|
||||
if warrant.Words != "" {
|
||||
why += " (" + warrant.Words + ")"
|
||||
}
|
||||
return k.endRetractionSaid(ctx, e, "the knowledge base", why)
|
||||
}
|
||||
option, err := warrant.For(Module, composeRetractAsk(cur, x))
|
||||
option, err := occ.For(warrant, Module, composeRetractAsk(cur, x))
|
||||
if err == nil {
|
||||
err = option.Performs(retractAct(cur, x, option.ID))
|
||||
err = occ.Performs(option, retractAct(cur, x, option.ID))
|
||||
}
|
||||
if err != nil {
|
||||
k.logf("[mesh-knowledge] REFUSED the answer to the ask to retract %s: %v", cur.Key(), err)
|
||||
return k.endRetractionSaid(ctx, e, "the knowledge base", "retraction not made: the answer does not hold here: "+err.Error())
|
||||
}
|
||||
if option.ID != OptionApprove {
|
||||
return k.endRetractionSaid(ctx, e, "the operator", "retraction of "+x.What()+" declined by the operator ("+warrant.Says()+")")
|
||||
return k.endRetractionSaid(ctx, e, "the operator", "retraction of "+x.What()+" declined by the operator ("+occ.Says(warrant)+")")
|
||||
}
|
||||
r := cur.Clone()
|
||||
done := *r.Retracting
|
||||
done.Ask.Said, done.Ask.Warrant, done.Ask.Held, done.Applied = AskApproved, warrant.Says(), "", k.now()
|
||||
done.Ask.Said, done.Ask.Warrant, done.Ask.Held, done.Applied = AskApproved, occ.Says(warrant), "", k.now()
|
||||
r.Retracting = nil
|
||||
if done.Whole() {
|
||||
done.Before, r.Status = r.Status, StatusRetracted
|
||||
@@ -345,7 +346,7 @@ func (k *KB) decideRetraction(ctx context.Context, e *Entry, warrant asks.Warran
|
||||
}
|
||||
}
|
||||
r.Retractions = append(r.Retractions, done)
|
||||
why := fmt.Sprintf("retracted %s on the operator's Approve, asked by %s: %s; warrant: %s", done.What(), done.By, done.Why, warrant.Says())
|
||||
why := fmt.Sprintf("retracted %s on the operator's Approve, asked by %s: %s; warrant: %s", done.What(), done.By, done.Why, occ.Says(warrant))
|
||||
if err := k.Store.Save(ctx, Version{By: "the operator", At: k.now(), Why: why, Record: r}, true, false, e.Revision); err != nil {
|
||||
return fmt.Sprintf("%s could not be retracted (%v); it holds as it did", r.Key(), err)
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
|
||||
)
|
||||
|
||||
// to-be 55 §10: "A record is accepted only after the knowledge base's own judgement approves it, and, when it needs
|
||||
@@ -248,9 +248,9 @@ func TestARecordThatNeedsTheOperatorIsAcceptedOnlyOnTheWarrant(t *testing.T) {
|
||||
t.Fatalf("after the review: %s, asks %d", e.Current.Status, len(w.router.asked))
|
||||
}
|
||||
ask := w.router.asked[0]
|
||||
if err := ask.Check(w.clock); err != nil || ask.Options[0].Level != asks.Approve || ask.Options[1].Level != asks.Approve ||
|
||||
if err := occ.Check(ask, w.clock); err != nil || ask.Options[0].Level != occ.Approve || ask.Options[1].Level != occ.Approve ||
|
||||
!strings.Contains(ask.Details, "the knowledge base's own review") || ask.Headline != "Merges land after ninety seconds of quiet" ||
|
||||
asks.Glance(ask.Explanation) != nil || strings.Contains(ask.Explanation, "The window closes") {
|
||||
occ.Glance(ask.Explanation) != nil || strings.Contains(ask.Explanation, "The window closes") {
|
||||
t.Fatalf("the ask: %v %+v", err, ask)
|
||||
}
|
||||
// Without the warrant: nothing is accepted, however often the router's record is read or the reviews run.
|
||||
@@ -275,7 +275,7 @@ func TestARecordThatNeedsTheOperatorIsAcceptedOnlyOnTheWarrant(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e := mustGet(t, s, Key{KindDecision, n})
|
||||
if e.Current.Status != StatusAccepted || e.Current.Ask == nil || e.Current.Ask.Warrant != warrant.Says() ||
|
||||
if e.Current.Status != StatusAccepted || e.Current.Ask == nil || e.Current.Ask.Warrant != occ.Says(warrant) ||
|
||||
e.Current.Ask.Said != AskApproved || len(said) != 1 {
|
||||
t.Fatalf("on the warrant: %+v, ask %+v, said %v", e.Current.Status, e.Current.Ask, said)
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
|
||||
)
|
||||
|
||||
// Rule is one rule that judges one record: its id and what it holds.
|
||||
@@ -654,7 +654,7 @@ func Holds(rule, kind, date string) bool {
|
||||
|
||||
// briefProblems are the ways a brief would not read at a glance on the operator's phone (novox/hq issue 639):
|
||||
// missing; a headline empty or longer than an ask's; a summary or a point the router could refuse, or that
|
||||
// names a path, an address or a long word (shown); more than three points, or one too long (asks.Glance).
|
||||
// names a path, an address or a long word (shown); more than three points, or one too long (occ.Glance).
|
||||
func briefProblems(b *Brief) []string {
|
||||
if b == nil {
|
||||
return []string{"a record that needs the operator carries a brief (headline, summary, points): what it changes for the operator, " +
|
||||
@@ -662,8 +662,8 @@ func briefProblems(b *Brief) []string {
|
||||
}
|
||||
var out []string
|
||||
h := strings.TrimSpace(b.Headline)
|
||||
if h == "" || strings.Contains(h, "\n") || len([]rune(h)) > asks.HeadlineLength {
|
||||
out = append(out, fmt.Sprintf("the brief's headline is empty, more than one line, or longer than %d characters", asks.HeadlineLength))
|
||||
if h == "" || strings.Contains(h, "\n") || len([]rune(h)) > occ.HeadlineLength {
|
||||
out = append(out, fmt.Sprintf("the brief's headline is empty, more than one line, or longer than %d characters", occ.HeadlineLength))
|
||||
}
|
||||
if s := strings.TrimSpace(b.Summary); s == "" || strings.Contains(s, "\n") {
|
||||
out = append(out, "the brief's summary is empty or more than one line")
|
||||
@@ -673,7 +673,7 @@ func briefProblems(b *Brief) []string {
|
||||
out = append(out, fmt.Sprintf("the brief's point %d is empty or more than one line", i+1))
|
||||
}
|
||||
}
|
||||
if err := asks.Glance(b.Explanation()); err != nil {
|
||||
if err := occ.Glance(b.Explanation()); err != nil {
|
||||
out = append(out, "the brief "+strings.TrimPrefix(err.Error(), "the explanation "))
|
||||
}
|
||||
for _, line := range append([]string{h}, strings.Split(b.Explanation(), "\n")...) {
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
|
||||
)
|
||||
|
||||
func TestAReservedRecordIsWithdrawnAtOnce(t *testing.T) {
|
||||
@@ -181,7 +181,7 @@ func TestARetractionTakesEffectOnlyOnTheOperatorsApprove(t *testing.T) {
|
||||
t.Fatalf("asked %d times", len(w.router.asked)-asked)
|
||||
}
|
||||
a := w.router.asked[len(w.router.asked)-1]
|
||||
if err := a.Check(w.clock); err != nil || asks.Glance(a.Explanation) != nil || len([]rune(a.Headline)) > 60 ||
|
||||
if err := occ.Check(a, w.clock); err != nil || occ.Glance(a.Explanation) != nil || len([]rune(a.Headline)) > 60 ||
|
||||
!strings.Contains(a.Headline, key.String()) || !strings.Contains(a.Explanation+a.Details, why) {
|
||||
t.Fatalf("the ask %+v: %v", a, err)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ module mesh-knowledge
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-contracts/go/seats/operatorchannel v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128
|
||||
github.com/jackc/pgx/v5 v5.11.0
|
||||
github.com/nats-io/nats-server/v2 v2.11.17
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
|
||||
)
|
||||
|
||||
// wholeAsk is every word the operator can ever see for one ask: its Headline, Explanation and Details
|
||||
@@ -26,7 +26,7 @@ func wholeAsk(r *sendRouter, id string) string {
|
||||
// judgedWords is exactly the text messenger refuses (or lets through) an ask as a whole on (asks.go's
|
||||
// TakeAsk): its headline, its explanation, what happens at expiry, and each option's label and effect.
|
||||
// Details is never part of it.
|
||||
func judgedWords(a asks.Ask) string {
|
||||
func judgedWords(a oc.Ask) string {
|
||||
words := []string{a.Headline, a.Explanation, a.OnExpiry}
|
||||
for _, o := range a.Options {
|
||||
words = append(words, o.Label, o.Does)
|
||||
@@ -36,7 +36,7 @@ func judgedWords(a asks.Ask) string {
|
||||
|
||||
// mustNotBeRefused fails the test naming what the content rule refused, and why — judged the way messenger
|
||||
// itself judges an ask as a whole, on judgedWords alone.
|
||||
func mustNotBeRefused(t *testing.T, a asks.Ask) {
|
||||
func mustNotBeRefused(t *testing.T, a oc.Ask) {
|
||||
t.Helper()
|
||||
if refusal, ok := Check(judgedWords(a)); !ok {
|
||||
t.Fatalf("the content rule refuses the ask: %s\n%s", refusal, judgedWords(a))
|
||||
|
||||
@@ -13,7 +13,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
|
||||
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
|
||||
)
|
||||
|
||||
// claimTestSend asks and approves one send (through the tools, as issue 1194 built it), leaving it queued,
|
||||
@@ -62,13 +63,13 @@ func rawQueuedSend(t *testing.T, sends *memSends) SendRequest {
|
||||
|
||||
// warrantFor is a well-formed warrant answering req's ask with Send, at, and the ask's own digest — the
|
||||
// same shape sendRouter.chose builds, for a test that never published the ask to a router at all.
|
||||
func warrantFor(req SendRequest, at time.Time) asks.Warrant {
|
||||
func warrantFor(req SendRequest, at time.Time) oc.Warrant {
|
||||
ask := req.Ask()
|
||||
opt, _ := ask.Option(OptionSend)
|
||||
return asks.Warrant{Ask: req.ID, Asker: Module, Outcome: asks.OutcomeChosen, Option: opt.ID, Label: opt.Label,
|
||||
opt, _ := occ.OptionOf(ask, OptionSend)
|
||||
return oc.Warrant{Ask: req.ID, Asker: Module, Outcome: occ.OutcomeChosen, Option: opt.ID, Label: opt.Label,
|
||||
Level: opt.Level,
|
||||
By: &asks.Person{Who: asks.Operator, Kind: "telegram", Identity: "42", Verified: "user id verified"},
|
||||
Channel: "telegram", Proofs: []string{"P1"}, At: at, AskDigest: ask.Digest()}
|
||||
By: &oc.Person{Who: occ.Operator, Kind: "telegram", Identity: "42", Verified: "user id verified"},
|
||||
Channel: "telegram", Proofs: []string{"P1"}, At: occ.FormatTime(at), AskDigest: occ.Digest(ask)}
|
||||
}
|
||||
|
||||
// 1196/AC1: given a queued send whose router record is closed by the operator's Approve at level approve
|
||||
@@ -134,7 +135,7 @@ func TestClaimQueuedSendRefusesAMismatchedOrUnclosedWarrant(t *testing.T) {
|
||||
t.Run("approved after the ask's expiry", func(t *testing.T) {
|
||||
sends, router := newMemSends(), newSendRouter()
|
||||
req := rawQueuedSend(t, sends)
|
||||
w := warrantFor(req, req.Ask().Expires.Add(time.Hour))
|
||||
w := warrantFor(req, expiresAt(req.Ask()).Add(time.Hour))
|
||||
router.close(w)
|
||||
emit := &memEmit{}
|
||||
_, d, _, outcome, err := claimQueuedSend(sends, router.Record, emit.emit, req.ID, judgedAt.Add(3*time.Minute))
|
||||
|
||||
@@ -90,7 +90,7 @@ func Check(text string) (Refusal, bool) {
|
||||
}
|
||||
|
||||
// CheckSecret says whether a text carries a secret's shape — the one class that leaves the mesh nowhere. It is
|
||||
// what an ask's whole words (asks.Ask.Whole, novox/hq issue 383) are held to: on a kind that proves who answers,
|
||||
// what an ask's whole words (oc.Ask.Whole, novox/hq issue 383) are held to: on a kind that proves who answers,
|
||||
// a path or an address is what the operator approves and may be shown, a secret never. A path is read as one:
|
||||
// a run with a slash in it is judged piece by piece between the slashes, so "/mnt/Photos_2024/Jochen" is a
|
||||
// path and not a random string (the review of 2026-10-10); the named shapes — a key block, a signed token, a
|
||||
|
||||
@@ -31,8 +31,8 @@ import (
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
)
|
||||
|
||||
// Module is this module's name.
|
||||
@@ -252,8 +252,8 @@ func waitsTellTicker(b *Bundle) {
|
||||
func hear(b *Bundle) {
|
||||
<-stdio.Served() // subscribed as soon as the runtime serves, never after a guessed wait: an event sent meanwhile is heard
|
||||
for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) {
|
||||
err := stdio.Subscribe(asks.Seat+".decided."+Module, func(e stdio.Envelope) error {
|
||||
var w asks.Warrant
|
||||
err := stdio.Subscribe(oc.Seat+".decided."+Module, func(e stdio.Envelope) error {
|
||||
var w oc.Warrant
|
||||
if json.Unmarshal(e.Body, &w) == nil {
|
||||
b.Heard(w)
|
||||
}
|
||||
|
||||
@@ -5,8 +5,8 @@ package main
|
||||
// this raises an ask on the operator channel (novox/hq ADR 0259) showing the account, the recipients, the subject
|
||||
// and the first lines of the body, and the tool returns at once with the ask's id. The mail reaches the account's
|
||||
// outgoing server only on the operator's Approve, on a channel that proves who answered, and once: the id is kept
|
||||
// with the decision it was given, so a warrant delivered twice is acted on once (asks.Warrant.For and
|
||||
// asks.Option.Performs hold the decision to exactly the request that was asked). A Decline, or no answer before it
|
||||
// with the decision it was given, so a warrant delivered twice is acted on once (oc.Warrant.For and
|
||||
// oc.Option.Performs hold the decision to exactly the request that was asked). A Decline, or no answer before it
|
||||
// expires, sends nothing, which mail_send_status says. The password is read only to sign in, and the body only to
|
||||
// send; neither is ever logged.
|
||||
//
|
||||
@@ -27,8 +27,9 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
|
||||
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
)
|
||||
|
||||
// SendsState is the module's state the sends are kept in.
|
||||
@@ -41,7 +42,7 @@ const (
|
||||
)
|
||||
|
||||
// SendAskLasts is how long the operator has to answer: a day less a margin, under the router's bound for an approval.
|
||||
const SendAskLasts = asks.ApproveLasts - 10*time.Minute
|
||||
const SendAskLasts = occ.ApproveLasts - 10*time.Minute
|
||||
|
||||
// StaleSendAfter is how long a send may read "sending" before mail_send_status stops calling it "waiting": past
|
||||
// this, the claim before the SMTP call is old enough that the send may or may not have gone out, and is never
|
||||
@@ -210,8 +211,8 @@ func bodyDigest(body string) string {
|
||||
}
|
||||
|
||||
// Act is what an answer binds: this request, whole, and the option chosen.
|
||||
func (s SendRequest) Act(option string) asks.Act {
|
||||
return asks.Act{"verb": Module + ".send", "option": option, "send": s.ID, "account": s.Account,
|
||||
func (s SendRequest) Act(option string) occ.Act {
|
||||
return occ.Act{"verb": Module + ".send", "option": option, "send": s.ID, "account": s.Account,
|
||||
"to": strings.Join(s.To, ","), "subject": s.Subject, "body-digest": bodyDigest(s.Body),
|
||||
"caller": s.Caller, "at": s.At.UTC().Format(time.RFC3339Nano)}
|
||||
}
|
||||
@@ -236,12 +237,12 @@ func firstChars(s string, n int) string {
|
||||
// address, a path-shaped reference, a hex hash, … novox/hq issue 868, review round 1), the subject and first
|
||||
// lines are left out of the Explanation and pointed at Details instead, so the ask always reaches the operator.
|
||||
// The mail itself is sent unchanged: neither masking touches s.To, s.Subject or s.Body.
|
||||
func (s SendRequest) Ask() asks.Ask {
|
||||
func (s SendRequest) Ask() oc.Ask {
|
||||
to := s.AskTo
|
||||
subject := maskText(s.Subject)
|
||||
body := maskText(firstChars(s.Body, ShownBodyChars))
|
||||
headline := fmt.Sprintf("Send mail from %s?", s.Account)
|
||||
if len([]rune(headline)) > asks.HeadlineLength {
|
||||
if len([]rune(headline)) > occ.HeadlineLength {
|
||||
headline = "Send mail?"
|
||||
}
|
||||
onExpiry := "nothing is sent"
|
||||
@@ -258,22 +259,22 @@ func (s SendRequest) Ask() asks.Ask {
|
||||
return strings.Join(words, "\n")
|
||||
}
|
||||
explanation := fmt.Sprintf("From %s to %s: %q.", s.Account, to, subject)
|
||||
if len([]rune(explanation)) > asks.SummaryLength {
|
||||
if len([]rune(explanation)) > occ.SummaryLength {
|
||||
explanation = fmt.Sprintf("From %s: %q.", s.Account, subject)
|
||||
}
|
||||
if _, ok := Check(judged(explanation)); !ok {
|
||||
explanation = fmt.Sprintf("From %s to %s. The subject and first lines are in Details.", s.Account, to)
|
||||
if len([]rune(explanation)) > asks.SummaryLength {
|
||||
if len([]rune(explanation)) > occ.SummaryLength {
|
||||
explanation = "Send mail. The subject and first lines are in Details."
|
||||
}
|
||||
}
|
||||
details := fmt.Sprintf("Asked by %s through the mesh-mail module, at %s UTC.\nAccount: %s\nTo: %s\nSubject: %s\n\n%s",
|
||||
s.Caller, s.At.UTC().Format("15:04 on 2 Jan"), s.Account, to, subject, body)
|
||||
a := asks.Ask{ID: s.ID, Headline: headline, Explanation: explanation, Details: details,
|
||||
Who: asks.Operator, Expires: s.At.Add(SendAskLasts), OnExpiry: onExpiry}
|
||||
a := oc.Ask{ID: s.ID, Headline: headline, Explanation: explanation, Details: details,
|
||||
Who: occ.Operator, Expires: occ.FormatTime(s.At.Add(SendAskLasts)), OnExpiry: onExpiry}
|
||||
for _, o := range options {
|
||||
binds, _ := asks.ActDigest(s.Act(o.id))
|
||||
a.Options = append(a.Options, asks.Option{ID: o.id, Label: o.label, Does: o.does, Level: asks.Approve, Binds: binds})
|
||||
binds, _ := occ.ActDigest(s.Act(o.id))
|
||||
a.Options = append(a.Options, oc.Option{ID: o.id, Label: o.label, Does: o.does, Level: occ.Approve, Binds: binds})
|
||||
}
|
||||
return a
|
||||
}
|
||||
@@ -339,8 +340,8 @@ func getSend(store SendStore, id string) (SendRequest, uint64, bool, error) {
|
||||
|
||||
// routerRecord is the router's record of one ask, as far as this module reads it.
|
||||
type routerRecord struct {
|
||||
State string `json:"state"`
|
||||
Warrant *asks.Warrant `json:"warrant"`
|
||||
State string `json:"state"`
|
||||
Warrant *oc.Warrant `json:"warrant"`
|
||||
}
|
||||
|
||||
// ---- asking ----------------------------------------------------------------------------------------------------
|
||||
@@ -421,7 +422,7 @@ func AskMailSend(in SendInput, store SendStore, bus AskBus, now time.Time) (map[
|
||||
req := SendRequest{ID: newSendID(), Account: in.Account.Name, To: to, Subject: subject, Body: in.Body,
|
||||
Caller: in.Caller, At: now.UTC(), AskTo: composeAskTo(to, in.Accounts)}
|
||||
ask := req.Ask()
|
||||
if err := ask.Check(now); err != nil {
|
||||
if err := occ.Check(ask, now); err != nil {
|
||||
return refuse(err.Error())
|
||||
}
|
||||
if _, err := store.Create(req.ID, req); err != nil {
|
||||
@@ -432,7 +433,7 @@ func AskMailSend(in SendInput, store SendStore, bus AskBus, now time.Time) (map[
|
||||
// sent (novox/hq issue 811, review round 3).
|
||||
return nil, fmt.Errorf("the send could not be made visible to the sweep, so the operator was not asked: %w. Nothing was sent", err)
|
||||
}
|
||||
if err := bus.Publish(asks.AskSubject(Module), ask, ask.ID); err != nil {
|
||||
if err := bus.Publish(oc.AcceptSubjectAsk(Module), ask, ask.ID); err != nil {
|
||||
return nil, fmt.Errorf("the operator could not be asked (%v). Nothing was sent; mail_send_status %s says so", err, req.ID)
|
||||
}
|
||||
return map[string]any{
|
||||
@@ -440,7 +441,7 @@ func AskMailSend(in SendInput, store SendStore, bus AskBus, now time.Time) (map[
|
||||
"status": "waiting",
|
||||
"asked": "\"" + ask.Headline + "\", to " + to[0] + onwards(to),
|
||||
"from": in.Account.Name,
|
||||
"expires": ask.Expires.Format(time.RFC3339),
|
||||
"expires": expiresAt(ask).Format(time.RFC3339),
|
||||
"next": "mail_send_status with this ask id says where it stands; nothing is sent until the operator's Approve, " +
|
||||
"and nothing on Decline or at expiry",
|
||||
}, nil
|
||||
@@ -622,12 +623,19 @@ func settleSend(store SendStore, req SendRequest, rev uint64, d SendDecision) (S
|
||||
return final, newRev, nil
|
||||
}
|
||||
|
||||
// Decide holds one closed ask's warrant to the request it answers (asks.Warrant.For, asks.Option.Performs), and —
|
||||
// expiresAt is when an ask ends, read from the contract's RFC 3339 text; the ask was checked (occ.Check) before
|
||||
// it was published, so the text is a time, and a text that is not reads as the zero time.
|
||||
func expiresAt(a oc.Ask) time.Time {
|
||||
t, _ := occ.Time(a.Expires)
|
||||
return t
|
||||
}
|
||||
|
||||
// Decide holds one closed ask's warrant to the request it answers (oc.Warrant.For, oc.Option.Performs), and —
|
||||
// on Approve, exactly once — records the send approved and queues it for the sender (novox/hq ADR 0436 clause 8);
|
||||
// it never sends. A request already decided is never decided again: decide answers its kept decision at once, and
|
||||
// two warrants for the same request (a redelivery, or two readers racing) have only the first claim the request
|
||||
// by a compare-and-set write; the second finds it already claimed or decided and queues nothing twice.
|
||||
func Decide(store SendStore, publish QueuePublish, w asks.Warrant, now time.Time) (SendDecision, error) {
|
||||
func Decide(store SendStore, publish QueuePublish, w oc.Warrant, now time.Time) (SendDecision, error) {
|
||||
req, rev, ok, err := getSend(store, w.Ask)
|
||||
if err != nil {
|
||||
return SendDecision{}, err
|
||||
@@ -645,7 +653,7 @@ func Decide(store SendStore, publish QueuePublish, w asks.Warrant, now time.Time
|
||||
final, _, err := settleSend(store, req, rev, d)
|
||||
return final, err
|
||||
}
|
||||
if w.Outcome != asks.OutcomeChosen {
|
||||
if w.Outcome != occ.OutcomeChosen {
|
||||
outcome := "ended: " + string(w.Outcome)
|
||||
if w.Words != "" {
|
||||
outcome += " (" + w.Words + ")"
|
||||
@@ -653,16 +661,16 @@ func Decide(store SendStore, publish QueuePublish, w asks.Warrant, now time.Time
|
||||
return settle(SendDecision{At: now, Outcome: outcome})
|
||||
}
|
||||
ask := req.Ask()
|
||||
option, err := w.For(Module, ask)
|
||||
option, err := occ.For(w, Module, ask)
|
||||
if err == nil {
|
||||
err = option.Performs(req.Act(option.ID))
|
||||
err = occ.Performs(option, req.Act(option.ID))
|
||||
}
|
||||
if err != nil {
|
||||
return settle(SendDecision{At: now, Outcome: "refused: " + err.Error()})
|
||||
}
|
||||
switch option.ID {
|
||||
case OptionDecline:
|
||||
return settle(SendDecision{At: now, Outcome: "declined", Warrant: w.Says()})
|
||||
return settle(SendDecision{At: now, Outcome: "declined", Warrant: occ.Says(w)})
|
||||
case OptionSend:
|
||||
return decideSend(store, publish, req, rev, w, now)
|
||||
default:
|
||||
@@ -681,12 +689,12 @@ func Decide(store SendStore, publish QueuePublish, w asks.Warrant, now time.Time
|
||||
// either way, and the sender's own claim (clause 9) keeps it one send. It never sends: the sender (clause 7)
|
||||
// takes the queued message, verifies the warrant again itself (clause 9) and performs the one handover to
|
||||
// the server (clause 10).
|
||||
func decideSend(store SendStore, publish QueuePublish, req SendRequest, rev uint64, w asks.Warrant, now time.Time) (SendDecision, error) {
|
||||
func decideSend(store SendStore, publish QueuePublish, req SendRequest, rev uint64, w oc.Warrant, now time.Time) (SendDecision, error) {
|
||||
// Indexed before the record carries "queued" (novox/hq review round 1 finding 3): a crash between this
|
||||
// and the write below must still leave it findable by mail_health's overdue check and the sweep's own
|
||||
// stale-queued retry once "queued" does land.
|
||||
indexErr := addSenderPending(store, req.ID)
|
||||
queued := SendDecision{At: now, Outcome: "queued", Warrant: w.Says()}
|
||||
queued := SendDecision{At: now, Outcome: "queued", Warrant: occ.Says(w)}
|
||||
claimed, newRev, err := settleSend(store, req, rev, queued)
|
||||
if err != nil {
|
||||
return SendDecision{}, errOr(indexErr, err)
|
||||
@@ -832,9 +840,9 @@ func claimQueuedSend(store SendStore, record RouterRecord, emit Emit, id string,
|
||||
return refuse(fmt.Sprintf("the warrant is for %s's ask, not %s's", w.Asker, Module))
|
||||
}
|
||||
ask := req.Ask()
|
||||
option, err := w.For(Module, ask)
|
||||
option, err := occ.For(w, Module, ask)
|
||||
if err == nil {
|
||||
err = option.Performs(req.Act(option.ID))
|
||||
err = occ.Performs(option, req.Act(option.ID))
|
||||
}
|
||||
if err != nil {
|
||||
return refuse(err.Error())
|
||||
@@ -842,7 +850,7 @@ func claimQueuedSend(store SendStore, record RouterRecord, emit Emit, id string,
|
||||
if option.ID != OptionSend {
|
||||
return refuse("the option " + option.ID + " does not authorise a send")
|
||||
}
|
||||
claim := SendDecision{At: now, Outcome: "sending", Warrant: w.Says(), Attempt: req.Decided.Attempt + 1}
|
||||
claim := SendDecision{At: now, Outcome: "sending", Warrant: occ.Says(w), Attempt: req.Decided.Attempt + 1}
|
||||
claimed, newRev, err := updateDecision(store, req, rev, claim)
|
||||
if err != nil {
|
||||
return SendRequest{}, SendDecision{}, 0, claimLostRace, err
|
||||
@@ -1428,7 +1436,7 @@ func reportDecision(out map[string]any, d SendDecision, now time.Time) {
|
||||
// Heard acts on one warrant the router's own subject delivered to this module directly (main.go's hear), through
|
||||
// the same Decide that mail_send_status uses: an approved send goes out as soon as the operator answers, never
|
||||
// only when something later happens to call mail_send_status (novox/hq ADR 0361 §2, review round 1 of issue 790).
|
||||
func (b *Bundle) Heard(w asks.Warrant) {
|
||||
func (b *Bundle) Heard(w oc.Warrant) {
|
||||
if _, err := Decide(b.sends(), b.queuePublish(), w, b.now()); err != nil {
|
||||
if b.Logf != nil {
|
||||
b.Logf("[mesh-mail] the warrant for the send %s could not be acted on: %v", w.Ask, err)
|
||||
|
||||
@@ -17,8 +17,9 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
|
||||
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
)
|
||||
|
||||
// ---- a fake `sends` state and a fake operator channel, as a test keeps them --------------------------------------
|
||||
@@ -93,14 +94,14 @@ func (m *memSends) Keys() ([]string, error) {
|
||||
// router's own record of each ask.
|
||||
type sendRouter struct {
|
||||
mu sync.Mutex
|
||||
published map[string]asks.Ask
|
||||
published map[string]oc.Ask
|
||||
records map[string]json.RawMessage
|
||||
publishErr error
|
||||
recordCalls map[string]int
|
||||
}
|
||||
|
||||
func newSendRouter() *sendRouter {
|
||||
return &sendRouter{published: map[string]asks.Ask{}, records: map[string]json.RawMessage{}, recordCalls: map[string]int{}}
|
||||
return &sendRouter{published: map[string]oc.Ask{}, records: map[string]json.RawMessage{}, recordCalls: map[string]int{}}
|
||||
}
|
||||
|
||||
func (r *sendRouter) Publish(subject string, body any, id string) error {
|
||||
@@ -110,7 +111,7 @@ func (r *sendRouter) Publish(subject string, body any, id string) error {
|
||||
return r.publishErr
|
||||
}
|
||||
raw, _ := json.Marshal(body)
|
||||
var a asks.Ask
|
||||
var a oc.Ask
|
||||
_ = json.Unmarshal(raw, &a)
|
||||
r.published[a.ID] = a
|
||||
r.records[a.ID], _ = json.Marshal(map[string]any{"state": "open"})
|
||||
@@ -143,21 +144,21 @@ func (r *sendRouter) Record(id string) (*stdio.StateEntry, error) {
|
||||
}
|
||||
|
||||
// close ends an ask with a warrant, as the router records it.
|
||||
func (r *sendRouter) close(w asks.Warrant) {
|
||||
func (r *sendRouter) close(w oc.Warrant) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.records[w.Ask], _ = json.Marshal(map[string]any{"state": string(w.Outcome), "warrant": w})
|
||||
}
|
||||
|
||||
// chose is the warrant for a person's choice of an option of the ask as the router took it.
|
||||
func (r *sendRouter) chose(id, option string, at time.Time) asks.Warrant {
|
||||
func (r *sendRouter) chose(id, option string, at time.Time) oc.Warrant {
|
||||
r.mu.Lock()
|
||||
a := r.published[id]
|
||||
r.mu.Unlock()
|
||||
o, _ := a.Option(option)
|
||||
return asks.Warrant{Ask: id, Asker: Module, Outcome: asks.OutcomeChosen, Option: o.ID, Label: o.Label, Level: o.Level,
|
||||
By: &asks.Person{Who: asks.Operator, Kind: "telegram", Identity: "42", Verified: "user id verified"},
|
||||
Channel: "telegram", Proofs: []string{"P1"}, At: at, AskDigest: a.Digest()}
|
||||
o, _ := occ.OptionOf(a, option)
|
||||
return oc.Warrant{Ask: id, Asker: Module, Outcome: occ.OutcomeChosen, Option: o.ID, Label: o.Label, Level: o.Level,
|
||||
By: &oc.Person{Who: occ.Operator, Kind: "telegram", Identity: "42", Verified: "user id verified"},
|
||||
Channel: "telegram", Proofs: []string{"P1"}, At: occ.FormatTime(at), AskDigest: occ.Digest(a)}
|
||||
}
|
||||
|
||||
func (r *sendRouter) published1() int {
|
||||
@@ -397,7 +398,7 @@ func TestMailSendAsksWithTheAccountRecipientsSubjectAndFirstLines(t *testing.T)
|
||||
t.Fatalf("published %d asks, not 1", n)
|
||||
}
|
||||
a := router.published[id]
|
||||
if err := a.Check(judgedAt); err != nil {
|
||||
if err := occ.Check(a, judgedAt); err != nil {
|
||||
t.Errorf("the ask does not pass the asks contract: %v", err)
|
||||
}
|
||||
whole := a.Headline + "\n" + a.Explanation + "\n" + a.Details
|
||||
@@ -410,7 +411,7 @@ func TestMailSendAsksWithTheAccountRecipientsSubjectAndFirstLines(t *testing.T)
|
||||
t.Errorf("the ask carries a recipient's mail address, which the operator channel's content rule refuses:\n%s", whole)
|
||||
}
|
||||
for _, o := range a.Options {
|
||||
if o.Level != asks.Approve || o.Binds == "" {
|
||||
if o.Level != occ.Approve || o.Binds == "" {
|
||||
t.Errorf("the option %s is %s binding %q: both answers are an approval bound to this send", o.ID, o.Level, o.Binds)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ module mesh-mail
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-contracts/go/seats/operatorchannel v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128
|
||||
github.com/emersion/go-imap/v2 v2.0.0-beta.8
|
||||
github.com/emersion/go-message v0.18.2
|
||||
|
||||
@@ -37,8 +37,9 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
|
||||
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/provisions"
|
||||
@@ -999,30 +1000,30 @@ const (
|
||||
)
|
||||
|
||||
// askLasts is how long a retiring ask stays open.
|
||||
const askLasts = asks.ApproveLasts - 10*time.Minute
|
||||
const askLasts = occ.ApproveLasts - 10*time.Minute
|
||||
|
||||
// retiringAsk is the ask of one retiring provision, composed from the key alone.
|
||||
func retiringAsk(key string, p provisions.Provision, at time.Time, id string) asks.Ask {
|
||||
func retiringAsk(key string, p provisions.Provision, at time.Time, id string) oc.Ask {
|
||||
what := fmt.Sprintf("%s's access to %s on %s, for %s on %s", p.Consumer.Module, p.Provision, p.Provider.Node,
|
||||
p.Consumer.Module, p.Consumer.Node)
|
||||
a := asks.Ask{ID: id, Headline: "Remove " + p.Consumer.Module + "'s " + p.Provision + " access?",
|
||||
a := oc.Ask{ID: id, Headline: "Remove " + p.Consumer.Module + "'s " + p.Provision + " access?",
|
||||
Explanation: fmt.Sprintf("Nothing wanted or placed on %s needs %s any more. On Approve, %s removes %s: what it owns "+
|
||||
"is handed to the resource's owner and its credential goes; the resource and its data stay.", p.Consumer.Node,
|
||||
p.Provision, p.Provider.Module, what),
|
||||
Who: asks.Operator, Expires: at.Add(askLasts), OnExpiry: "nothing is removed; it is asked again when it changes",
|
||||
Who: occ.Operator, Expires: occ.FormatTime(at.Add(askLasts)), OnExpiry: "nothing is removed; it is asked again when it changes",
|
||||
About: "provision." + key, Details: "The provision " + key + " of mesh-provisioner's state provisions."}
|
||||
for _, o := range []struct{ id, label, does string }{
|
||||
{OptionApprove, "Remove", "the provider removes the credential; the resource and its data stay"},
|
||||
{OptionDecline, "Keep", "nothing is removed, and it is not asked again while nothing changes"},
|
||||
} {
|
||||
binds, _ := asks.ActDigest(retireAct(key, id, o.id))
|
||||
a.Options = append(a.Options, asks.Option{ID: o.id, Label: o.label, Does: o.does, Level: asks.Approve, Binds: binds})
|
||||
binds, _ := occ.ActDigest(retireAct(key, id, o.id))
|
||||
a.Options = append(a.Options, oc.Option{ID: o.id, Label: o.label, Does: o.does, Level: occ.Approve, Binds: binds})
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
func retireAct(key, id, option string) asks.Act {
|
||||
return asks.Act{"verb": "mesh-provisioner.retire", "provision": key, "ask": id, "option": option}
|
||||
func retireAct(key, id, option string) occ.Act {
|
||||
return occ.Act{"verb": "mesh-provisioner.retire", "provision": key, "ask": id, "option": option}
|
||||
}
|
||||
|
||||
// askOpenAtMost is how many of its asks mesh-provisioner keeps open at once: the router shows at most three per asker
|
||||
@@ -1068,10 +1069,10 @@ func (h *Holder) ask(p *provisions.Provision, key string) error {
|
||||
now := h.Now().UTC()
|
||||
id := "retire-" + strings.ReplaceAll(key, ".", "-") + "-" + fmt.Sprint(now.Unix())
|
||||
a := retiringAsk(key, *p, now, id)
|
||||
if err := a.Check(now); err != nil {
|
||||
if err := occ.Check(a, now); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := h.Asks.Publish(asks.AskSubject(Module), a, id); err != nil {
|
||||
if err := h.Asks.Publish(oc.AcceptSubjectAsk(Module), a, id); err != nil {
|
||||
return fmt.Errorf("the retiring ask of %s could not be published: %w", key, err)
|
||||
}
|
||||
p.Ask = id
|
||||
@@ -1141,9 +1142,9 @@ func (h *Holder) Decide() []string {
|
||||
continue
|
||||
}
|
||||
var rec struct {
|
||||
State string `json:"state"`
|
||||
Ask *asks.Ask `json:"ask"`
|
||||
Warrant *asks.Warrant `json:"warrant"`
|
||||
State string `json:"state"`
|
||||
Ask *oc.Ask `json:"ask"`
|
||||
Warrant *oc.Warrant `json:"warrant"`
|
||||
}
|
||||
if json.Unmarshal(raw, &rec) != nil || rec.State == "open" || rec.Warrant == nil || rec.Ask == nil {
|
||||
continue
|
||||
@@ -1153,10 +1154,10 @@ func (h *Holder) Decide() []string {
|
||||
continue
|
||||
}
|
||||
option := ""
|
||||
if w.Outcome == asks.OutcomeChosen {
|
||||
o, err := w.For(Module, *rec.Ask)
|
||||
if w.Outcome == occ.OutcomeChosen {
|
||||
o, err := occ.For(w, Module, *rec.Ask)
|
||||
if err == nil {
|
||||
err = o.Performs(retireAct(k, p.Ask, o.ID))
|
||||
err = occ.Performs(o, retireAct(k, p.Ask, o.ID))
|
||||
}
|
||||
if err != nil {
|
||||
said = append(said, k+": the answer is refused: "+err.Error())
|
||||
@@ -1174,7 +1175,12 @@ func (h *Holder) Decide() []string {
|
||||
if w.By != nil {
|
||||
by = fmt.Sprintf("the %s, as %s identity %s", w.By.Who, w.By.Kind, w.By.Identity)
|
||||
}
|
||||
p.Warrant = &provisions.Warrant{Ask: p.Ask, Option: option, By: by, At: w.At.UTC()}
|
||||
answeredAt, err := occ.Time(w.At)
|
||||
if err != nil {
|
||||
said = append(said, k+": the answer is refused: its time is not RFC 3339 ("+err.Error()+")")
|
||||
continue
|
||||
}
|
||||
p.Warrant = &provisions.Warrant{Ask: p.Ask, Option: option, By: by, At: answeredAt.UTC()}
|
||||
h.put(k, p)
|
||||
said = append(said, k+": "+option+" by "+by)
|
||||
}
|
||||
|
||||
@@ -21,8 +21,8 @@ import (
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
|
||||
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/provisions"
|
||||
"git.novox.be/novox/mesh-sdk/go/testbus"
|
||||
@@ -104,7 +104,7 @@ type asked struct{ ids []string }
|
||||
|
||||
func (a *asked) Publish(_ string, body any, id string) error {
|
||||
a.ids = append(a.ids, id)
|
||||
_ = body.(asks.Ask)
|
||||
_ = body.(oc.Ask)
|
||||
return nil
|
||||
}
|
||||
func (a *asked) Record(string) ([]byte, bool, error) { return nil, false, nil }
|
||||
|
||||
@@ -13,8 +13,9 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
|
||||
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/provisions"
|
||||
@@ -23,13 +24,13 @@ import (
|
||||
// router is the operator's channel as the holder asks on it: it holds an ask open, or, like the router past its open
|
||||
// asks per asker, holds no record of it (drop).
|
||||
type router struct {
|
||||
asked map[string]asks.Ask
|
||||
asked map[string]oc.Ask
|
||||
records map[string][]byte
|
||||
drop bool
|
||||
}
|
||||
|
||||
func (r *router) Publish(_ string, body any, id string) error {
|
||||
r.asked[id] = body.(asks.Ask)
|
||||
r.asked[id] = body.(oc.Ask)
|
||||
if !r.drop {
|
||||
r.records[id], _ = json.Marshal(map[string]any{"state": "open", "ask": body})
|
||||
}
|
||||
@@ -45,13 +46,13 @@ func (r *router) Record(id string) ([]byte, bool, error) {
|
||||
func (r *router) answer(t *testing.T, id, option string) {
|
||||
t.Helper()
|
||||
a := r.asked[id]
|
||||
o, ok := a.Option(option)
|
||||
o, ok := occ.OptionOf(a, option)
|
||||
if !ok {
|
||||
t.Fatalf("the ask %s offers no %s", id, option)
|
||||
}
|
||||
w := asks.Warrant{Ask: id, Asker: Module, About: a.About, Outcome: asks.OutcomeChosen, Option: o.ID, Label: o.Label,
|
||||
Level: o.Level, By: &asks.Person{Who: asks.Operator, Kind: "telegram", Identity: "42"}, Channel: "telegram",
|
||||
At: time.Date(2026, 10, 29, 23, 0, 0, 0, time.UTC), AskDigest: a.Digest()}
|
||||
w := oc.Warrant{Ask: id, Asker: Module, About: a.About, Outcome: occ.OutcomeChosen, Option: o.ID, Label: o.Label,
|
||||
Level: o.Level, By: &oc.Person{Who: occ.Operator, Kind: "telegram", Identity: "42"}, Channel: "telegram",
|
||||
At: occ.FormatTime(time.Date(2026, 10, 29, 23, 0, 0, 0, time.UTC)), AskDigest: occ.Digest(a)}
|
||||
r.records[id], _ = json.Marshal(map[string]any{"state": "closed", "ask": a, "warrant": w})
|
||||
}
|
||||
|
||||
@@ -70,7 +71,7 @@ type mesh struct {
|
||||
}
|
||||
|
||||
func newMesh() *mesh {
|
||||
m := &mesh{s: newStore(), r: &router{asked: map[string]asks.Ask{}, records: map[string][]byte{}},
|
||||
m := &mesh{s: newStore(), r: &router{asked: map[string]oc.Ask{}, records: map[string][]byte{}},
|
||||
atWant: map[string]map[string]catalogue.Plan{}, atNewest: map[string]map[string]catalogue.Plan{},
|
||||
heldAt: map[string]map[string]string{}, unpublished: map[string][]string{}}
|
||||
now := time.Date(2026, 10, 29, 22, 0, 0, 0, time.UTC)
|
||||
|
||||
@@ -15,8 +15,8 @@ import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/handacts"
|
||||
@@ -279,7 +279,7 @@ func main() {
|
||||
go func() {
|
||||
<-stdio.Served() // subscribed as soon as the runtime serves, never after a guessed wait: an event sent meanwhile is heard
|
||||
for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) {
|
||||
err := stdio.Subscribe(asks.Seat+".decided."+Module, func(stdio.Envelope) error {
|
||||
err := stdio.Subscribe(oc.Seat+".decided."+Module, func(stdio.Envelope) error {
|
||||
for _, said := range h.Decide() {
|
||||
logf("[%s] %s", Module, said)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ module mesh-provisioner
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-contracts/go/seats/operatorchannel v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8
|
||||
github.com/nats-io/nats.go v1.54.0
|
||||
)
|
||||
|
||||
@@ -26,6 +26,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-contracts/go/seats/meshvault"
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
@@ -148,10 +149,10 @@ type EnrolmentTokenArgs struct {
|
||||
Drop bool
|
||||
}
|
||||
|
||||
// EnrolmentTokenAnswer is mesh-sdk's delivery.EnrolmentTokenAnswer, the wire shape mesh-enrolment decodes: at issue the
|
||||
// EnrolmentTokenAnswer is mesh-sdk's meshvault.EnrolmentTokenAnswer, the wire shape mesh-enrolment decodes: at issue the
|
||||
// bus's address, its certificate's fingerprint and the vault's public signing key; when sealed, the whole token sealed to
|
||||
// the operator key; at drop, whether it was dropped. Never a token in the clear.
|
||||
type EnrolmentTokenAnswer = delivery.EnrolmentTokenAnswer
|
||||
type EnrolmentTokenAnswer = meshvault.EnrolmentTokenAnswer
|
||||
|
||||
// enrolTunnel is the vault's name of the first tunnel a node's token carries, kept beside the token.
|
||||
func enrolTunnel(node string) string { return "enrol/" + node + "/tunnel" }
|
||||
@@ -364,10 +365,10 @@ type NodeAccountArgs struct {
|
||||
Retire bool
|
||||
}
|
||||
|
||||
// NodeAccountAnswer is mesh-sdk's delivery.NodeAccountAnswer, the wire shape mesh-enrolment decodes and relays into the
|
||||
// NodeAccountAnswer is mesh-sdk's meshvault.NodeAccountAnswer, the wire shape mesh-enrolment decodes and relays into the
|
||||
// redeem answer: the login, and the account sealed to the presented key, as the node-engine opens it (the password,
|
||||
// sealed anonymously to its raw X25519 key, in base64); or that it was retired.
|
||||
type NodeAccountAnswer = delivery.NodeAccountAnswer
|
||||
type NodeAccountAnswer = meshvault.NodeAccountAnswer
|
||||
|
||||
// NodeAccount answers caller's `node-account`: the joining node's account, minted once for a node and a set of keys and
|
||||
// answered sealed to the key presented; or, with retire, taken away again at a revoke.
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-contracts/go/seats/meshvault"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"golang.org/x/crypto/nacl/box"
|
||||
@@ -187,9 +188,9 @@ func TestTheVaultsEnrolmentAnswersAreMeshSDKsWireShapes(t *testing.T) {
|
||||
}
|
||||
reply := delivery.RedeemAnswer{Node: "new1", Login: "node.new1", Account: "sealed", Certificate: "pem", Signer: signerB64}
|
||||
|
||||
var issued, sealed, dropped delivery.EnrolmentTokenAnswer
|
||||
var account, accountAgain, retired delivery.NodeAccountAnswer
|
||||
var cert, replySig delivery.SignAnswer
|
||||
var issued, sealed, dropped meshvault.EnrolmentTokenAnswer
|
||||
var account, accountAgain, retired meshvault.NodeAccountAnswer
|
||||
var cert, replySig meshvault.SignAnswer
|
||||
var secret, accountHash string
|
||||
for _, c := range []struct {
|
||||
name, verb, caller string
|
||||
@@ -282,7 +283,7 @@ func TestTheVaultsEnrolmentAnswersAreMeshSDKsWireShapes(t *testing.T) {
|
||||
t.Errorf("the sealed token is %+v, %v", tok, err)
|
||||
}
|
||||
// The account opens with the sealing key presented, as the node-engine opens it, and is the same both times.
|
||||
open := func(a delivery.NodeAccountAnswer) string {
|
||||
open := func(a meshvault.NodeAccountAnswer) string {
|
||||
blob, _ := base64.StdEncoding.DecodeString(a.Account)
|
||||
var pub, priv [32]byte
|
||||
copy(pub[:], sealingKey.PublicKey().Bytes())
|
||||
|
||||
@@ -20,7 +20,7 @@ import (
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/sdk/views"
|
||||
"git.novox.be/novox/mesh-sdk/go/views"
|
||||
)
|
||||
|
||||
func say(format string, args ...any) {
|
||||
|
||||
@@ -17,7 +17,7 @@ import (
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/sdk/views"
|
||||
"git.novox.be/novox/mesh-sdk/go/views"
|
||||
)
|
||||
|
||||
// PlacementTool is the assigner's ordinary read tool of its placement; PlacementViewsEvent the event of its views family.
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/sdk/views"
|
||||
"git.novox.be/novox/mesh-sdk/go/views"
|
||||
)
|
||||
|
||||
// ADR 0615 §6: the vault started while the assigner is down keeps asking, never reads its bucket, and past the bound
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-contracts/go/seats/meshvault"
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/overlay"
|
||||
@@ -254,9 +255,9 @@ type PresentedKeys struct {
|
||||
// Sign answers caller's `sign`. The caller is the one the runtime proved; who may ask which kind is the vault's rule,
|
||||
// never an argument: a node certificate for a machine's node-engine alone, about its own machine; an enrolment for no
|
||||
// caller until adoption's record names one (ADR 0561 §5); the authority for the vault itself, never asked.
|
||||
func (v *Vault) Sign(caller string, a SignArgs) (delivery.SignAnswer, error) {
|
||||
func (v *Vault) Sign(caller string, a SignArgs) (meshvault.SignAnswer, error) {
|
||||
if caller == "" || caller == stdio.Unproven || !busUser.MatchString(caller) {
|
||||
return delivery.SignAnswer{}, errors.New("the vault signs only for a caller the runtime proved")
|
||||
return meshvault.SignAnswer{}, errors.New("the vault signs only for a caller the runtime proved")
|
||||
}
|
||||
switch a.Kind {
|
||||
case delivery.KindNodeCertificate:
|
||||
@@ -266,14 +267,14 @@ func (v *Vault) Sign(caller string, a SignArgs) (delivery.SignAnswer, error) {
|
||||
return v.signNodeCertificate(caller, a.Node)
|
||||
case delivery.KindEnrolment:
|
||||
if err := v.fromEnrolment(caller, "sign an enrolment"); err != nil {
|
||||
return delivery.SignAnswer{}, fmt.Errorf("%s may not ask for an enrolment's signature: only the seat %s, which "+
|
||||
return meshvault.SignAnswer{}, fmt.Errorf("%s may not ask for an enrolment's signature: only the seat %s, which "+
|
||||
"decides who joins, is named for it (novox/hq ADR 0561 §5, ADR 0587 §6)", caller, EnrolmentModule)
|
||||
}
|
||||
return v.signEnrolment(caller, a.Node, a.Reply)
|
||||
case delivery.KindAuthority:
|
||||
return delivery.SignAnswer{}, fmt.Errorf("the authority is the vault's own, signed for itself; %s may not ask it", caller)
|
||||
return meshvault.SignAnswer{}, fmt.Errorf("the authority is the vault's own, signed for itself; %s may not ask it", caller)
|
||||
}
|
||||
return delivery.SignAnswer{}, fmt.Errorf("%q is not a kind the vault signs (%s)", a.Kind, strings.Join(delivery.SignKinds, ", "))
|
||||
return meshvault.SignAnswer{}, fmt.Errorf("%q is not a kind the vault signs (%s)", a.Kind, strings.Join(delivery.SignKinds, ", "))
|
||||
}
|
||||
|
||||
// redeeming says node has an enrolment token held: a machine joining, which the seat mesh-enrolment redeems.
|
||||
@@ -293,107 +294,107 @@ func (v *Vault) redeeming(node string) error {
|
||||
// (ADR 0587 §6). The machine has written no record yet, so the serving key is the one it presented at the redeem, which
|
||||
// counts only when the four keys presented have the digest node-account recorded for its account; the name is its
|
||||
// placement's, which mesh-enrolment writes before it asks.
|
||||
func (v *Vault) signRedeemingCertificate(caller, node string, keys PresentedKeys) (delivery.SignAnswer, error) {
|
||||
func (v *Vault) signRedeemingCertificate(caller, node string, keys PresentedKeys) (meshvault.SignAnswer, error) {
|
||||
if err := v.redeeming(node); err != nil {
|
||||
return delivery.SignAnswer{}, err
|
||||
return meshvault.SignAnswer{}, err
|
||||
}
|
||||
if v.Accounts == nil {
|
||||
return delivery.SignAnswer{}, errors.New("the vault has no state of bus accounts, so it cannot read the keys a joining node presented")
|
||||
return meshvault.SignAnswer{}, errors.New("the vault has no state of bus accounts, so it cannot read the keys a joining node presented")
|
||||
}
|
||||
acct, err := v.Accounts.Get(NodeEnginePrefix + node)
|
||||
if err != nil {
|
||||
return delivery.SignAnswer{}, fmt.Errorf("%s %s could not be read: %w", BusAccountsState, NodeEnginePrefix+node, err)
|
||||
return meshvault.SignAnswer{}, fmt.Errorf("%s %s could not be read: %w", BusAccountsState, NodeEnginePrefix+node, err)
|
||||
}
|
||||
if acct == nil || acct.Kept != KeptNode || acct.KeysDigest == "" {
|
||||
return delivery.SignAnswer{}, fmt.Errorf("%s has no account made at its redeem (node-account), so no keys are recorded to certify", node)
|
||||
return meshvault.SignAnswer{}, fmt.Errorf("%s has no account made at its redeem (node-account), so no keys are recorded to certify", node)
|
||||
}
|
||||
if keys.Serving == "" || keys.Public == "" || keys.Sealing == "" {
|
||||
return delivery.SignAnswer{}, errors.New("a joining node's certificate is asked with the keys it presented at the redeem " +
|
||||
return meshvault.SignAnswer{}, errors.New("a joining node's certificate is asked with the keys it presented at the redeem " +
|
||||
"(`public_key`, `overlay_key`, `sealing_key`, `serving_key`), which are checked against node-account's digest")
|
||||
}
|
||||
if got := delivery.KeysDigest(keys.Public, keys.Overlay, keys.Sealing, keys.Serving); got != acct.KeysDigest {
|
||||
return delivery.SignAnswer{}, fmt.Errorf("the keys presented for %s are not the ones its account was made for: nothing is signed", node)
|
||||
return meshvault.SignAnswer{}, fmt.Errorf("the keys presented for %s are not the ones its account was made for: nothing is signed", node)
|
||||
}
|
||||
pub, err := base64.StdEncoding.DecodeString(keys.Serving)
|
||||
if err != nil || len(pub) != ed25519.PublicKeySize {
|
||||
return delivery.SignAnswer{}, fmt.Errorf("%s's serving key is not an ed25519 public key in base64", node)
|
||||
return meshvault.SignAnswer{}, fmt.Errorf("%s's serving key is not an ed25519 public key in base64", node)
|
||||
}
|
||||
name, err := v.internalName(node)
|
||||
if err != nil {
|
||||
return delivery.SignAnswer{}, err
|
||||
return meshvault.SignAnswer{}, err
|
||||
}
|
||||
return v.signCertificateWith(caller, node, name, ed25519.PublicKey(pub))
|
||||
}
|
||||
|
||||
// signEnrolment signs the bytes of an enrolment reply under the enrolment context, for a node that is joining; the
|
||||
// signature is the vault's key's, and the answer carries it with the signer's fingerprint.
|
||||
func (v *Vault) signEnrolment(caller, node, reply string) (delivery.SignAnswer, error) {
|
||||
func (v *Vault) signEnrolment(caller, node, reply string) (meshvault.SignAnswer, error) {
|
||||
if err := v.redeeming(node); err != nil {
|
||||
return delivery.SignAnswer{}, err
|
||||
return meshvault.SignAnswer{}, err
|
||||
}
|
||||
msg, err := base64.StdEncoding.DecodeString(reply)
|
||||
if err != nil || len(msg) == 0 {
|
||||
return delivery.SignAnswer{}, errors.New("an enrolment's signature is asked for the reply's bytes in base64 (`reply`)")
|
||||
return meshvault.SignAnswer{}, errors.New("an enrolment's signature is asked for the reply's bytes in base64 (`reply`)")
|
||||
}
|
||||
context, err := delivery.SignContext(delivery.KindEnrolment)
|
||||
if err != nil {
|
||||
return delivery.SignAnswer{}, err
|
||||
return meshvault.SignAnswer{}, err
|
||||
}
|
||||
k, err := v.keys()
|
||||
if err != nil {
|
||||
return delivery.SignAnswer{}, err
|
||||
return meshvault.SignAnswer{}, err
|
||||
}
|
||||
answer := delivery.SignAnswer{Kind: delivery.KindEnrolment, Node: node,
|
||||
answer := meshvault.SignAnswer{Kind: delivery.KindEnrolment, Node: node,
|
||||
Signature: base64.StdEncoding.EncodeToString(delivery.SignMessage(k.key, context, msg)),
|
||||
Signer: delivery.SignerFingerprint(k.key.Public().(ed25519.PublicKey)), Digest: delivery.DigestOf(msg)}
|
||||
if err := v.saySigned(caller, SigningKeySecret, answer); err != nil {
|
||||
return delivery.SignAnswer{}, err
|
||||
return meshvault.SignAnswer{}, err
|
||||
}
|
||||
return answer, nil
|
||||
}
|
||||
|
||||
// signNodeCertificate signs the certificate of the calling machine's internal name for the serving key its record states.
|
||||
func (v *Vault) signNodeCertificate(caller, asked string) (delivery.SignAnswer, error) {
|
||||
func (v *Vault) signNodeCertificate(caller, asked string) (meshvault.SignAnswer, error) {
|
||||
node, ok := nodeEngineOf(caller)
|
||||
if !ok {
|
||||
return delivery.SignAnswer{}, fmt.Errorf("a node certificate is signed for a machine's node-engine alone, about its own "+
|
||||
return meshvault.SignAnswer{}, fmt.Errorf("a node certificate is signed for a machine's node-engine alone, about its own "+
|
||||
"machine; %s is not a node-engine", caller)
|
||||
}
|
||||
if asked != "" && asked != node {
|
||||
return delivery.SignAnswer{}, fmt.Errorf("%s asks for the certificate of %s, and is %s's node-engine: a machine asks for its own alone", caller, asked, node)
|
||||
return meshvault.SignAnswer{}, fmt.Errorf("%s asks for the certificate of %s, and is %s's node-engine: a machine asks for its own alone", caller, asked, node)
|
||||
}
|
||||
return v.signCertificateFor(caller, node)
|
||||
}
|
||||
|
||||
// signCertificateFor signs node's certificate from its placement's name and its record's serving key.
|
||||
func (v *Vault) signCertificateFor(caller, node string) (delivery.SignAnswer, error) {
|
||||
func (v *Vault) signCertificateFor(caller, node string) (meshvault.SignAnswer, error) {
|
||||
name, err := v.internalName(node)
|
||||
if err != nil {
|
||||
return delivery.SignAnswer{}, err
|
||||
return meshvault.SignAnswer{}, err
|
||||
}
|
||||
serving, err := v.servingKey(node)
|
||||
if err != nil {
|
||||
return delivery.SignAnswer{}, err
|
||||
return meshvault.SignAnswer{}, err
|
||||
}
|
||||
return v.signCertificateWith(caller, node, name, serving)
|
||||
}
|
||||
|
||||
// signCertificateWith signs the certificate of name for serving, for node.
|
||||
func (v *Vault) signCertificateWith(caller, node, name string, serving ed25519.PublicKey) (delivery.SignAnswer, error) {
|
||||
func (v *Vault) signCertificateWith(caller, node, name string, serving ed25519.PublicKey) (meshvault.SignAnswer, error) {
|
||||
k, err := v.keys()
|
||||
if err != nil {
|
||||
return delivery.SignAnswer{}, err
|
||||
return meshvault.SignAnswer{}, err
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, delivery.NodeCertificateTemplate(name, serving, k.authorityMade),
|
||||
k.parsed, serving, k.authority)
|
||||
if err != nil {
|
||||
return delivery.SignAnswer{}, fmt.Errorf("the certificate of %s could not be signed: %w", name, err)
|
||||
return meshvault.SignAnswer{}, fmt.Errorf("the certificate of %s could not be signed: %w", name, err)
|
||||
}
|
||||
fp, _ := delivery.AuthorityFingerprint(k.certificate)
|
||||
answer := delivery.SignAnswer{Kind: delivery.KindNodeCertificate, Node: node, Authority: k.certificate,
|
||||
answer := meshvault.SignAnswer{Kind: delivery.KindNodeCertificate, Node: node, Authority: k.certificate,
|
||||
Certificate: string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})), Signer: fp, Digest: delivery.DigestOf(der)}
|
||||
if err := v.saySigned(caller, AuthorityKeySecret, answer); err != nil {
|
||||
return delivery.SignAnswer{}, err
|
||||
return meshvault.SignAnswer{}, err
|
||||
}
|
||||
return answer, nil
|
||||
}
|
||||
@@ -449,7 +450,7 @@ func (v *Vault) servingKey(node string) (ed25519.PublicKey, error) {
|
||||
|
||||
// saySigned keeps the signature in the record of hand-outs and says `signed`: who, which kind, which node, the digest and
|
||||
// the signer's fingerprint. Never a key. A signature whose record cannot be written is not answered.
|
||||
func (v *Vault) saySigned(caller, with string, a delivery.SignAnswer) error {
|
||||
func (v *Vault) saySigned(caller, with string, a meshvault.SignAnswer) error {
|
||||
at := v.Now().UTC()
|
||||
if err := v.recordHanding(Handing{To: caller, Name: with, Outcome: signedOutcome,
|
||||
Why: a.Kind + " " + a.Node + " " + a.Digest}); err != nil {
|
||||
@@ -458,11 +459,11 @@ func (v *Vault) saySigned(caller, with string, a delivery.SignAnswer) error {
|
||||
if v.Emit == nil {
|
||||
return nil
|
||||
}
|
||||
raw, _ := json.Marshal(delivery.Signed{Caller: caller, Kind: a.Kind, Node: a.Node, Digest: a.Digest, Signer: a.Signer, At: at})
|
||||
raw, _ := json.Marshal(meshvault.SignedHolderEvent{Caller: caller, Kind: a.Kind, Node: a.Node, Digest: a.Digest, Signer: a.Signer, At: at.Format(time.RFC3339Nano)})
|
||||
var body map[string]any
|
||||
_ = json.Unmarshal(raw, &body)
|
||||
if err := v.Emit(delivery.SignedEvent, body); err != nil {
|
||||
v.log("signed %s for %s, but %s could not be said: %v", a.Kind, caller, delivery.SignedEvent, err)
|
||||
if err := v.Emit(meshvault.HolderEventSigned, body); err != nil {
|
||||
v.log("signed %s for %s, but %s could not be said: %v", a.Kind, caller, meshvault.HolderEventSigned, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -3,8 +3,7 @@ module mesh-vault
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-delivery/sdk v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-contracts/go v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-contracts/go/seats/meshvault v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929
|
||||
github.com/nats-io/nats-server/v2 v2.11.17
|
||||
github.com/nats-io/nats.go v1.54.0
|
||||
|
||||
@@ -26,7 +26,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
ch "git.novox.be/novox/mesh-sdk/go/channel"
|
||||
)
|
||||
|
||||
// Action is one answer a condition names: a label, and the seat verb its producer calls when it is chosen.
|
||||
@@ -132,7 +132,7 @@ func pruneGrants(grants map[string]Grant, kind string, now time.Time, keep int)
|
||||
// Chosen takes an answer a channel of a kind said it was given: an ask's option is judged and, when it
|
||||
// stands, closes the ask with a warrant for its asker (asks.go); Details are shown where they were asked.
|
||||
// Anything else is refused, and said there. The same answer is acted on once.
|
||||
func (h *Holder) Chosen(kind string, c asks.Choice) (string, error) {
|
||||
func (h *Holder) Chosen(kind string, c ch.Choice) (string, error) {
|
||||
// The root-free word is asked before the work is taken (the confirmation review of 2026-10-09):
|
||||
// up to RootFreeWithin, which no ask, answer or link should wait behind. Asked fresh here, for this answer,
|
||||
// and judged with in it.
|
||||
@@ -255,7 +255,7 @@ func firstLineOf(s string) string {
|
||||
|
||||
// Failed takes a channel's word that a message did not arrive: the channel is said to be failing, and the
|
||||
// record it carried is tried again — unless the failure is permanent, which is said and not repeated.
|
||||
func (h *Holder) Failed(kind string, f asks.Failed) {
|
||||
func (h *Holder) Failed(kind string, f ch.Failed) {
|
||||
h.work.Lock()
|
||||
defer h.work.Unlock()
|
||||
h.noteChannel(kind, errorf("%s", f.Why))
|
||||
|
||||
@@ -10,7 +10,9 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
|
||||
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
|
||||
ch "git.novox.be/novox/mesh-sdk/go/channel"
|
||||
)
|
||||
|
||||
// The router of novox/hq ADR 0259: it holds every ask, judges each answer and issues a warrant; it
|
||||
@@ -161,9 +163,9 @@ func (m *memAsks) PutLinksAsked(key string, times []time.Time) error {
|
||||
var rootFreeOf = map[*Holder]map[string]string{}
|
||||
|
||||
// told is what the router published to askers, by subject.
|
||||
type told struct{ by map[string][]asks.Warrant }
|
||||
type told struct{ by map[string][]oc.Warrant }
|
||||
|
||||
func (w *told) of(asker string) []asks.Warrant { return w.by[asks.DecidedSubject(asker)] }
|
||||
func (w *told) of(asker string) []oc.Warrant { return w.by[oc.SubjectDecided(asker)] }
|
||||
|
||||
// newRouter is a router with two kinds: telegram, which reaches the operator away and verifies its sender,
|
||||
// and the desk, which does neither — as the controller would issue them from the claims.
|
||||
@@ -171,7 +173,7 @@ func newRouter(t *testing.T) (*Holder, *fakeChannel, *fakeChannel, *clock, *memA
|
||||
t.Helper()
|
||||
h, tg, dt, c, _ := newHolder(t)
|
||||
store := newMemAsks()
|
||||
w := &told{by: map[string][]asks.Warrant{}}
|
||||
w := &told{by: map[string][]oc.Warrant{}}
|
||||
bench := NewBench(c.now)
|
||||
bench.SetKinds([]KindInfo{
|
||||
{Kind: "telegram", Module: "telegram", Node: "anchor", Capabilities: []string{"deliver", "reaches-away", "choice", "edit",
|
||||
@@ -201,7 +203,7 @@ func newRouter(t *testing.T) (*Holder, *fakeChannel, *fakeChannel, *clock, *memA
|
||||
}
|
||||
h.Zone = func() *time.Location { return time.UTC }
|
||||
h.Publish = func(subject string, body any, id string) error {
|
||||
if wt, ok := body.(asks.Warrant); ok {
|
||||
if wt, ok := body.(oc.Warrant); ok {
|
||||
w.by[subject] = append(w.by[subject], wt)
|
||||
}
|
||||
return nil
|
||||
@@ -219,25 +221,25 @@ func stand(h *Holder, c *clock) {
|
||||
|
||||
// bound is what an option of the held delivery's ask binds: the asker's act, digested.
|
||||
func bound(verb string) string {
|
||||
d, err := asks.ActDigest(asks.Act{"verb": "mesh-delivery." + verb, "id": "novox/hq@0555"})
|
||||
d, err := occ.ActDigest(occ.Act{"verb": "mesh-delivery." + verb, "id": "novox/hq@0555"})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func heldAsk(c *clock, id string) asks.Ask {
|
||||
return asks.Ask{ID: id, Headline: "Delivery of hq held for 36 hours",
|
||||
func heldAsk(c *clock, id string) oc.Ask {
|
||||
return oc.Ask{ID: id, Headline: "Delivery of hq held for 36 hours",
|
||||
Explanation: "Needs you: release it, or stop it. A delivery of hq has been held for 36 hours.",
|
||||
Who: asks.Operator, Expires: c.now().Add(2 * time.Hour), OnExpiry: "the delivery stays held",
|
||||
Options: []asks.Option{
|
||||
{ID: "release", Label: "Release", Does: "the delivery goes on", Level: asks.Approve, Binds: bound("release")},
|
||||
{ID: "stop", Label: "Stop", Does: "the delivery ends", Level: asks.Approve, Binds: bound("stop")},
|
||||
{ID: "silence", Label: "Silence for a week", Does: "nothing more is said of it for a week", Level: asks.Acknowledge},
|
||||
Who: occ.Operator, Expires: occ.FormatTime(c.now().Add(2 * time.Hour)), OnExpiry: "the delivery stays held",
|
||||
Options: []oc.Option{
|
||||
{ID: "release", Label: "Release", Does: "the delivery goes on", Level: occ.Approve, Binds: bound("release")},
|
||||
{ID: "stop", Label: "Stop", Does: "the delivery ends", Level: occ.Approve, Binds: bound("stop")},
|
||||
{ID: "silence", Label: "Silence for a week", Does: "nothing more is said of it for a week", Level: occ.Acknowledge},
|
||||
}}
|
||||
}
|
||||
|
||||
func submit(t *testing.T, h *Holder, asker string, a asks.Ask) {
|
||||
func submit(t *testing.T, h *Holder, asker string, a oc.Ask) {
|
||||
t.Helper()
|
||||
raw, _ := json.Marshal(a)
|
||||
if err := h.TakeAsk("ask", asker, raw); err != nil {
|
||||
@@ -250,9 +252,9 @@ func link(h *Holder, store *memAsks, c *clock, identity string, at time.Time) {
|
||||
Linked: at, ApproveFrom: at.Add(LinkedApproveAfter)}
|
||||
}
|
||||
|
||||
func choiceOf(ticket, identity string, at time.Time) asks.Choice {
|
||||
return asks.Choice{ID: "c-" + ticket + "-" + identity, Ticket: ticket, At: at,
|
||||
Sender: asks.Sender{Identity: identity, Display: "Jo", Authenticated: true}}
|
||||
func choiceOf(ticket, identity string, at time.Time) ch.Choice {
|
||||
return ch.Choice{ID: "c-" + ticket + "-" + identity, Ticket: ticket, At: at,
|
||||
Sender: ch.Sender{Identity: identity, Display: "Jo", Authenticated: true}}
|
||||
}
|
||||
|
||||
func ticketFor(m Message, label string) string {
|
||||
@@ -273,7 +275,7 @@ func labelsOf(m Message) string {
|
||||
}
|
||||
|
||||
// ackAsk is an ask of acknowledgements alone: silencing the held delivery.
|
||||
func ackAsk(c *clock, id string) asks.Ask {
|
||||
func ackAsk(c *clock, id string) oc.Ask {
|
||||
a := heldAsk(c, id)
|
||||
a.Options = a.Options[2:]
|
||||
a.Explanation = "Needs you: silence it, or leave it. A delivery of hq has been held for 36 hours."
|
||||
@@ -342,21 +344,21 @@ func TestAnApproveFromTheLinkedAccountIsAWarrantForTheAskerAlone(t *testing.T) {
|
||||
t.Fatalf("%s %v", out, err)
|
||||
}
|
||||
got := w.of("mesh-delivery")
|
||||
if len(got) != 1 || got[0].Outcome != asks.OutcomeChosen || got[0].Option != "release" || got[0].Channel != "telegram" ||
|
||||
got[0].Says() != "the operator, via telegram (user id verified), chose Release" || strings.Join(got[0].Proofs, ",") != "P1" {
|
||||
if len(got) != 1 || got[0].Outcome != occ.OutcomeChosen || got[0].Option != "release" || got[0].Channel != "telegram" ||
|
||||
occ.Says(got[0]) != "the operator, via telegram (user id verified), chose Release" || strings.Join(got[0].Proofs, ",") != "P1" {
|
||||
t.Fatalf("the warrant: %+v", got)
|
||||
}
|
||||
if _, err := got[0].For("mesh-delivery", asked); err != nil {
|
||||
if _, err := occ.For(got[0], "mesh-delivery", asked); err != nil {
|
||||
t.Errorf("the asker would not act on it: %v", err)
|
||||
}
|
||||
// It answers the ask as it was taken, and no other ask under that id (ADR 0259 §6).
|
||||
if got[0].AskDigest != asked.Digest() {
|
||||
t.Errorf("the warrant names the digest %s, not the ask's %s", got[0].AskDigest, asked.Digest())
|
||||
if got[0].AskDigest != occ.Digest(asked) {
|
||||
t.Errorf("the warrant names the digest %s, not the ask's %s", got[0].AskDigest, occ.Digest(asked))
|
||||
}
|
||||
if _, err := got[0].For("mesh-delivery", heldAsk(c, "d-1")); err == nil {
|
||||
if _, err := occ.For(got[0], "mesh-delivery", heldAsk(c, "d-1")); err == nil {
|
||||
t.Error("a warrant was taken for an ask under the same id with another expiry")
|
||||
}
|
||||
if rec := store.asks["mesh-delivery.d-1"]; rec.State != string(asks.OutcomeChosen) || !rec.Told || !rec.Tickets[release].Used {
|
||||
if rec := store.asks["mesh-delivery.d-1"]; rec.State != string(occ.OutcomeChosen) || !rec.Told || !rec.Tickets[release].Used {
|
||||
t.Errorf("the record: %+v", rec)
|
||||
}
|
||||
// Every copy says how it ended, its buttons gone.
|
||||
@@ -490,7 +492,7 @@ func TestALinkApprovesAfterAnHourAndAcknowledgesAtOnce(t *testing.T) {
|
||||
if out, err := h2.Chosen("telegram", choiceOf(silence, "42", c2.now())); err != nil || out != "chosen" {
|
||||
t.Fatalf("an acknowledgement from a fresh link was refused: %s %v", out, err)
|
||||
}
|
||||
if got := w2.of("mesh-delivery"); len(got) != 1 || got[0].Level != asks.Acknowledge {
|
||||
if got := w2.of("mesh-delivery"); len(got) != 1 || got[0].Level != occ.Acknowledge {
|
||||
t.Errorf("%+v", got)
|
||||
}
|
||||
_ = w
|
||||
@@ -573,7 +575,7 @@ func TestTheDeskAcknowledgesAndNeverApproves(t *testing.T) {
|
||||
func TestAnAskIsRefusedInWordsToItsAsker(t *testing.T) {
|
||||
h, _, _, c, store, w := newRouter(t)
|
||||
bad := heldAsk(c, "d-1")
|
||||
bad.Options[0].Level = asks.Destroy
|
||||
bad.Options[0].Level = occ.Destroy
|
||||
submit(t, h, "mesh-delivery", bad)
|
||||
carrying := heldAsk(c, "d-2")
|
||||
carrying.Explanation = "Needs you: see /etc/passwd on the machine."
|
||||
@@ -586,7 +588,7 @@ func TestAnAskIsRefusedInWordsToItsAsker(t *testing.T) {
|
||||
got := w.of("mesh-delivery")
|
||||
var refused []string
|
||||
for _, g := range got {
|
||||
if g.Outcome == asks.OutcomeRefused {
|
||||
if g.Outcome == occ.OutcomeRefused {
|
||||
refused = append(refused, g.Ask+": "+g.Words)
|
||||
}
|
||||
}
|
||||
@@ -629,7 +631,7 @@ func TestAnAskExpiresAndNeverDefaults(t *testing.T) {
|
||||
stand(h, c)
|
||||
h.Tick()
|
||||
got := w.of("mesh-delivery")
|
||||
if len(got) != 1 || got[0].Outcome != asks.OutcomeExpired || got[0].Option != "" || !strings.Contains(got[0].Words, "the delivery stays held") {
|
||||
if len(got) != 1 || got[0].Outcome != occ.OutcomeExpired || got[0].Option != "" || !strings.Contains(got[0].Words, "the delivery stays held") {
|
||||
t.Fatalf("%+v", got)
|
||||
}
|
||||
var edited Message
|
||||
@@ -650,14 +652,14 @@ func TestANewerAskAboutTheSameThingReplacesTheOlderAndACancelEndsIt(t *testing.T
|
||||
b := ackAsk(c, "d-2")
|
||||
b.About = "delivery.hq"
|
||||
submit(t, h, "mesh-delivery", b)
|
||||
if store.asks["mesh-delivery.d-1"].State != string(asks.OutcomeReplaced) {
|
||||
if store.asks["mesh-delivery.d-1"].State != string(occ.OutcomeReplaced) {
|
||||
t.Errorf("the older ask was not replaced: %s", store.asks["mesh-delivery.d-1"].State)
|
||||
}
|
||||
if err := h.TakeAsk("cancel", "mesh-delivery", json.RawMessage(`{"id":"d-2"}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := w.of("mesh-delivery")
|
||||
if len(got) != 2 || got[0].Outcome != asks.OutcomeReplaced || got[1].Outcome != asks.OutcomeCancelled {
|
||||
if len(got) != 2 || got[0].Outcome != occ.OutcomeReplaced || got[1].Outcome != occ.OutcomeCancelled {
|
||||
t.Errorf("%+v", got)
|
||||
}
|
||||
// Another asker cannot cancel it: its cancel names its own ask, under its own name.
|
||||
@@ -735,7 +737,7 @@ func TestAnAnswerReplayedLongAfterIsNotActedOn(t *testing.T) {
|
||||
|
||||
func TestLinkingShowsACodeOnTheDeskAndLinksOnlyWhoAsked(t *testing.T) {
|
||||
h, tg, dt, c, store, _ := newRouter(t)
|
||||
asked := asks.LinkAsked{ID: "l1", At: c.now(), Sender: asks.Sender{Identity: "42", Display: "Jo", Authenticated: true}}
|
||||
asked := ch.LinkAsked{ID: "l1", At: c.now(), Sender: ch.Sender{Identity: "42", Display: "Jo", Authenticated: true}}
|
||||
h.Link("telegram", asked)
|
||||
if len(dt.sends) != 1 || !strings.HasPrefix(dt.sends[0].Title, "Link a Telegram account as you") {
|
||||
t.Fatalf("no code on the desk: %+v", dt.sends)
|
||||
@@ -748,13 +750,13 @@ func TestLinkingShowsACodeOnTheDeskAndLinksOnlyWhoAsked(t *testing.T) {
|
||||
t.Errorf("the code is kept in clear: %+v", p)
|
||||
}
|
||||
// Another account sending the code is refused; a wrong code is refused; the right one links.
|
||||
if a := h.Prove("telegram", asks.Code{Sender: asks.Sender{Identity: "666", Authenticated: true}, Code: code}); a.Accepted {
|
||||
if a := h.Prove("telegram", ch.Code{Sender: ch.Sender{Identity: "666", Authenticated: true}, Code: code}); a.Accepted {
|
||||
t.Error("another account linked with the code")
|
||||
}
|
||||
if a := h.Prove("telegram", asks.Code{Sender: asks.Sender{Identity: "42", Authenticated: true}, Code: "000000x"}); a.Accepted {
|
||||
if a := h.Prove("telegram", ch.Code{Sender: ch.Sender{Identity: "42", Authenticated: true}, Code: "000000x"}); a.Accepted {
|
||||
t.Error("a wrong code linked")
|
||||
}
|
||||
a := h.Prove("telegram", asks.Code{Sender: asks.Sender{Identity: "42", Authenticated: true}, Code: code, Purpose: "link"})
|
||||
a := h.Prove("telegram", ch.Code{Sender: ch.Sender{Identity: "42", Authenticated: true}, Code: code, Purpose: "link"})
|
||||
if !a.Accepted || !strings.Contains(a.Words, "approve from 13:00") {
|
||||
t.Fatalf("the right code: %+v (code %q)", a, code)
|
||||
}
|
||||
@@ -769,7 +771,7 @@ func TestLinkingShowsACodeOnTheDeskAndLinksOnlyWhoAsked(t *testing.T) {
|
||||
}
|
||||
}
|
||||
// A second account cannot link while one is.
|
||||
h.Link("telegram", asks.LinkAsked{ID: "l2", At: c.now(), Sender: asks.Sender{Identity: "666", Authenticated: true}})
|
||||
h.Link("telegram", ch.LinkAsked{ID: "l2", At: c.now(), Sender: ch.Sender{Identity: "666", Authenticated: true}})
|
||||
if _, pending := store.links["telegram"]; pending {
|
||||
t.Error("a second link was begun while one is linked")
|
||||
}
|
||||
@@ -777,21 +779,21 @@ func TestLinkingShowsACodeOnTheDeskAndLinksOnlyWhoAsked(t *testing.T) {
|
||||
|
||||
func TestALinkCodeExpiresAndFiveWrongTriesEndIt(t *testing.T) {
|
||||
h, _, _, c, store, _ := newRouter(t)
|
||||
jo := asks.Sender{Identity: "42", Authenticated: true}
|
||||
h.Link("telegram", asks.LinkAsked{ID: "l1", At: c.now(), Sender: jo})
|
||||
jo := ch.Sender{Identity: "42", Authenticated: true}
|
||||
h.Link("telegram", ch.LinkAsked{ID: "l1", At: c.now(), Sender: jo})
|
||||
for i := 0; i < LinkTries; i++ {
|
||||
h.Prove("telegram", asks.Code{Sender: jo, Code: "nope"})
|
||||
h.Prove("telegram", ch.Code{Sender: jo, Code: "nope"})
|
||||
}
|
||||
if _, pending := store.links["telegram"]; pending {
|
||||
t.Error("five wrong codes did not end the link")
|
||||
}
|
||||
h.Link("telegram", asks.LinkAsked{ID: "l2", At: c.now(), Sender: jo})
|
||||
h.Link("telegram", ch.LinkAsked{ID: "l2", At: c.now(), Sender: jo})
|
||||
c.pass(LinkCodeFor)
|
||||
if a := h.Prove("telegram", asks.Code{Sender: jo, Code: "123456"}); a.Accepted || !strings.Contains(a.Words, "expired") {
|
||||
if a := h.Prove("telegram", ch.Code{Sender: jo, Code: "123456"}); a.Accepted || !strings.Contains(a.Words, "expired") {
|
||||
t.Errorf("%+v", a)
|
||||
}
|
||||
// The desk cannot be linked: it proves nothing about who clicked.
|
||||
h.Link("desktop", asks.LinkAsked{ID: "l3", At: c.now(), Sender: asks.Sender{Identity: "g14"}})
|
||||
h.Link("desktop", ch.LinkAsked{ID: "l3", At: c.now(), Sender: ch.Sender{Identity: "g14"}})
|
||||
if _, pending := store.links["desktop"]; pending {
|
||||
t.Error("the desk began a link")
|
||||
}
|
||||
@@ -847,9 +849,9 @@ func TestARelinkWithinADayWaitsForTheReplacedAccount(t *testing.T) {
|
||||
link(h, store, c, "42", c.now().Add(-2*time.Hour))
|
||||
_, _ = h.Unlink("telegram")
|
||||
_, _ = h.Chosen("telegram", choiceOf(ticketFor(tg.sends[len(tg.sends)-1], "Unlink"), "42", c.now()))
|
||||
h.Link("telegram", asks.LinkAsked{ID: "l9", At: c.now(), Sender: asks.Sender{Identity: "666", Display: "Eve", Authenticated: true}})
|
||||
h.Link("telegram", ch.LinkAsked{ID: "l9", At: c.now(), Sender: ch.Sender{Identity: "666", Display: "Eve", Authenticated: true}})
|
||||
code := codeOf(t, dt.sends[len(dt.sends)-1])
|
||||
a := h.Prove("telegram", asks.Code{Sender: asks.Sender{Identity: "666", Authenticated: true}, Code: code})
|
||||
a := h.Prove("telegram", ch.Code{Sender: ch.Sender{Identity: "666", Authenticated: true}, Code: code})
|
||||
if !a.Accepted || !strings.Contains(a.Words, "waits for that account's approval") || len(store.ids) != 0 {
|
||||
t.Fatalf("linked at once within a day of an unlink: %+v %v", a, store.ids)
|
||||
}
|
||||
@@ -945,7 +947,7 @@ func TestNoApprovalWhileAnAgentCanBecomeRootThere(t *testing.T) {
|
||||
if len(w.of("mesh-delivery")) != 0 {
|
||||
t.Error("a warrant was issued")
|
||||
}
|
||||
if got := h.carriers(asks.Approve); len(got) != 0 {
|
||||
if got := h.carriers(occ.Approve); len(got) != 0 {
|
||||
t.Errorf("an approval is still offered on %v", got)
|
||||
}
|
||||
}
|
||||
@@ -969,7 +971,7 @@ func TestAnAnswerNeedsAChannelThatRendersExactly(t *testing.T) {
|
||||
h, _, _, c, _, _ := newRouter(t)
|
||||
h.Bench.SetKinds([]KindInfo{{Kind: "desktop", Module: "desk-channel", Capabilities: []string{"deliver", "choice", "private"}}})
|
||||
h.Bench.Stand("desktop", Standing{Ready: true, At: c.now()})
|
||||
if got := h.carriers(asks.Acknowledge); len(got) != 0 {
|
||||
if got := h.carriers(occ.Acknowledge); len(got) != 0 {
|
||||
t.Errorf("a channel without exact-render carries %v", got)
|
||||
}
|
||||
}
|
||||
@@ -977,12 +979,12 @@ func TestAnAnswerNeedsAChannelThatRendersExactly(t *testing.T) {
|
||||
// At most three links an hour per kind, and the code is shown only where the words stay private.
|
||||
func TestLinksAreBoundedAndTheCodeShownOnlyInPrivate(t *testing.T) {
|
||||
h, tg, dt, c, store, _ := newRouter(t)
|
||||
jo := asks.Sender{Identity: "42", Display: "Jo", Authenticated: true}
|
||||
jo := ch.Sender{Identity: "42", Display: "Jo", Authenticated: true}
|
||||
for i := 0; i < LinksAnHour; i++ {
|
||||
h.Link("telegram", asks.LinkAsked{ID: fmt.Sprint("l", i), At: c.now(), Sender: jo})
|
||||
h.Link("telegram", ch.LinkAsked{ID: fmt.Sprint("l", i), At: c.now(), Sender: jo})
|
||||
}
|
||||
shown := len(dt.sends)
|
||||
h.Link("telegram", asks.LinkAsked{ID: "l9", At: c.now(), Sender: jo})
|
||||
h.Link("telegram", ch.LinkAsked{ID: "l9", At: c.now(), Sender: jo})
|
||||
if len(dt.sends) != shown || !strings.Contains(tg.sends[len(tg.sends)-1].Body, "Try again in an hour") {
|
||||
t.Error("a fourth link this hour showed a code")
|
||||
}
|
||||
@@ -993,7 +995,7 @@ func TestLinksAreBoundedAndTheCodeShownOnlyInPrivate(t *testing.T) {
|
||||
h2.Bench.SetKinds([]KindInfo{
|
||||
{Kind: "telegram", Module: "telegram", Capabilities: []string{"choice", "verified-sender", "exact-render", "reaches-away"}},
|
||||
{Kind: "desktop", Module: "desk-channel", Capabilities: []string{"choice", "exact-render"}}})
|
||||
h2.Link("telegram", asks.LinkAsked{ID: "l1", At: c2.now(), Sender: jo})
|
||||
h2.Link("telegram", ch.LinkAsked{ID: "l1", At: c2.now(), Sender: jo})
|
||||
if _, pending := store2.links["telegram"]; pending || !strings.Contains(tg2.sends[len(tg2.sends)-1].Body, "No private place") {
|
||||
t.Error("a code was shown where the words are not private")
|
||||
}
|
||||
@@ -1011,7 +1013,7 @@ func TestAWarningTheDeskCouldNotShowGoesAway(t *testing.T) {
|
||||
if len(dt.sends) != 1 {
|
||||
t.Fatalf("the desk was sent %d", len(dt.sends))
|
||||
}
|
||||
h.Failed("desktop", asks.Failed{Handle: "g14=1", Why: "no session answers", At: c.now()})
|
||||
h.Failed("desktop", ch.Failed{Handle: "g14=1", Why: "no session answers", At: c.now()})
|
||||
c.pass(BurstWindow)
|
||||
h.Flush()
|
||||
if len(tg.sends) == 0 {
|
||||
|
||||
@@ -28,7 +28,9 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
|
||||
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
|
||||
ch "git.novox.be/novox/mesh-sdk/go/channel"
|
||||
)
|
||||
|
||||
// ErrChanged is a compare-and-set that lost: another writer was first.
|
||||
@@ -38,7 +40,7 @@ var ErrChanged = errors.New("written by another writer first")
|
||||
// operator on 2026-10-08, until an authenticator-app code exists).
|
||||
const LinkedApproveAfter = time.Hour
|
||||
|
||||
// StateOpen is an ask not yet ended; every other state is the asks.Outcome it ended with, but one.
|
||||
// StateOpen is an ask not yet ended; every other state is the string it ended with, but one.
|
||||
const StateOpen = "open"
|
||||
|
||||
// StateHeld is an ask that broke no rule and that no ready channel can carry now: kept as an open ask, up to its
|
||||
@@ -56,12 +58,12 @@ type Ticket struct {
|
||||
// it there under its own name.
|
||||
type AskRecord struct {
|
||||
Asker string `json:"asker"`
|
||||
Ask asks.Ask `json:"ask"`
|
||||
Ask oc.Ask `json:"ask"`
|
||||
State string `json:"state"`
|
||||
Opened time.Time `json:"opened"`
|
||||
Closed time.Time `json:"closed,omitempty"`
|
||||
Tickets map[string]Ticket `json:"tickets,omitempty"`
|
||||
Warrant *asks.Warrant `json:"warrant,omitempty"`
|
||||
Warrant *oc.Warrant `json:"warrant,omitempty"`
|
||||
// Told: the warrant, or the end, was published to the asker.
|
||||
Told bool `json:"told,omitempty"`
|
||||
// HeldSince and HeldWhy say, while the ask is held, since when and why no channel can carry it: per kind, the
|
||||
@@ -172,7 +174,7 @@ func (h *Holder) openAsk(r *Record) *AskRecord {
|
||||
return &rec
|
||||
}
|
||||
|
||||
// wholeOf is an ask's explanation with its values whole (asks.Ask.Whole), where an ask's own message on one
|
||||
// wholeOf is an ask's explanation with its values whole (oc.Ask.Whole), where an ask's own message on one
|
||||
// kind shows it in place of the explanation (novox/hq issue 383): on a kind that proves who answers by the
|
||||
// controller's fresh word (Promises, as every other verified-sender judgement here), and that the router gave
|
||||
// the ask's answers (a ticket there, so the kind was linked and root-free when the ask was taken). Empty where
|
||||
@@ -187,7 +189,7 @@ func (h *Holder) wholeOf(r *Record, kind string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// wholeDetailsOf is an ask's Details with their values whole (asks.Ask.WholeDetails), shown in the note in place of
|
||||
// wholeDetailsOf is an ask's Details with their values whole (oc.Ask.WholeDetails), shown in the note in place of
|
||||
// its Details where wholeOf would show its whole words: every value an approval sets, so the operator reads exactly
|
||||
// what they approve (the review of mesh-controller #304). Empty elsewhere.
|
||||
func (h *Holder) wholeDetailsOf(r *Record, kind string) string {
|
||||
@@ -199,7 +201,7 @@ func (h *Holder) wholeDetailsOf(r *Record, kind string) string {
|
||||
|
||||
// provenAsk is the ask of an ask's own record when the kind proves who answers and was given the ask's answers;
|
||||
// nil otherwise.
|
||||
func (h *Holder) provenAsk(r *Record, kind string) *asks.Ask {
|
||||
func (h *Holder) provenAsk(r *Record, kind string) *oc.Ask {
|
||||
if r == nil || r.Origin != "ask" || r.Ask == "" || kind == "" || h.Bench == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -219,7 +221,7 @@ func (h *Holder) provenAsk(r *Record, kind string) *asks.Ask {
|
||||
// carriers are the kinds that can carry an answer of a level now: ready, offering a choice, and for
|
||||
// approve verifying their sender with an account of the operator's linked, where the controller says now
|
||||
// that no agent can become root (rootFree, asked live).
|
||||
func (h *Holder) carriers(level asks.Level) []string {
|
||||
func (h *Holder) carriers(level string) []string {
|
||||
if h.Bench == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -230,9 +232,9 @@ func (h *Holder) carriers(level asks.Level) []string {
|
||||
continue
|
||||
}
|
||||
switch level {
|
||||
case asks.Acknowledge:
|
||||
case occ.Acknowledge:
|
||||
out = append(out, k.Kind)
|
||||
case asks.Approve:
|
||||
case occ.Approve:
|
||||
if h.Bench.Promises(k.Kind, "verified-sender") && h.linked(k.Kind) && h.rootFree(k, true) == "" {
|
||||
out = append(out, k.Kind)
|
||||
}
|
||||
@@ -472,11 +474,18 @@ func (h *Holder) approveHolds(kind string, cached bool) string {
|
||||
// offers an approval is answered only where it could be approved, whichever of its options is chosen, so an
|
||||
// acknowledgement from where approval is not proven never ends a question that waits for one (the review of
|
||||
// 2026-10-09).
|
||||
func answerLevel(a asks.Ask) asks.Level {
|
||||
if a.Highest().Rank() > asks.Acknowledge.Rank() {
|
||||
return a.Highest()
|
||||
// askExpires is when an ask ends: its expiry as the contract carries it (RFC 3339), or the zero time when it
|
||||
// carries none or an unreadable one (an ask that passed occ.Check has a readable one).
|
||||
func askExpires(a oc.Ask) time.Time {
|
||||
t, _ := occ.Time(a.Expires)
|
||||
return t
|
||||
}
|
||||
|
||||
func answerLevel(a oc.Ask) string {
|
||||
if occ.Rank(occ.Highest(a)) > occ.Rank(occ.Acknowledge) {
|
||||
return occ.Highest(a)
|
||||
}
|
||||
return asks.Acknowledge
|
||||
return occ.Acknowledge
|
||||
}
|
||||
|
||||
// ConditionOwner is the module whose asks may be shown on a condition's message: the condition keeper, which raises
|
||||
@@ -489,7 +498,7 @@ const ConditionOwner = ConditionSeat
|
||||
func (h *Holder) TakeAsk(verb, asker string, body json.RawMessage) error {
|
||||
if verb == ConditionVerb {
|
||||
// Before the work lock: Condition takes it, and decides as for the controller's conditions.
|
||||
if !asks.UsableID(strings.ReplaceAll(asker, "-", "_")) {
|
||||
if !occ.UsableID(strings.ReplaceAll(asker, "-", "_")) {
|
||||
h.Logf("[messenger] a condition from %q was taken and dropped: not an asker's name (a module's, or a node's engine's)", asker)
|
||||
return nil
|
||||
}
|
||||
@@ -502,7 +511,7 @@ func (h *Holder) TakeAsk(verb, asker string, body json.RawMessage) error {
|
||||
h.mu.Lock()
|
||||
h.init()
|
||||
h.mu.Unlock()
|
||||
if !asks.UsableID(strings.ReplaceAll(asker, "-", "_")) {
|
||||
if !occ.UsableID(strings.ReplaceAll(asker, "-", "_")) {
|
||||
h.Logf("[messenger] an ask from %q was taken and dropped: not an asker's name (a module's, or a node's engine's)", asker)
|
||||
return nil
|
||||
}
|
||||
@@ -510,15 +519,15 @@ func (h *Holder) TakeAsk(verb, asker string, body json.RawMessage) error {
|
||||
var c struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if json.Unmarshal(body, &c) != nil || !asks.UsableID(c.ID) {
|
||||
if json.Unmarshal(body, &c) != nil || !occ.UsableID(c.ID) {
|
||||
h.Logf("[messenger] a cancel from %s names no ask", asker)
|
||||
return nil
|
||||
}
|
||||
return h.closeAsk(askKey(asker, c.ID), asks.OutcomeCancelled, "its asker took it back", nil, "", nil)
|
||||
return h.closeAsk(askKey(asker, c.ID), occ.OutcomeCancelled, "its asker took it back", nil, "", nil)
|
||||
}
|
||||
var a asks.Ask
|
||||
var a oc.Ask
|
||||
if err := json.Unmarshal(body, &a); err != nil {
|
||||
h.refuseAsk(asker, asks.Ask{ID: "unreadable"}, "it could not be read: "+err.Error())
|
||||
h.refuseAsk(asker, oc.Ask{ID: "unreadable"}, "it could not be read: "+err.Error())
|
||||
return nil
|
||||
}
|
||||
key := askKey(asker, a.ID)
|
||||
@@ -526,7 +535,7 @@ func (h *Holder) TakeAsk(verb, asker string, body json.RawMessage) error {
|
||||
return nil // a redelivery: already recorded
|
||||
}
|
||||
now := h.Now()
|
||||
if err := a.Check(now); err != nil {
|
||||
if err := occ.Check(a, now); err != nil {
|
||||
h.refuseAsk(asker, a, err.Error())
|
||||
return nil
|
||||
}
|
||||
@@ -539,8 +548,8 @@ func (h *Holder) TakeAsk(verb, asker string, body json.RawMessage) error {
|
||||
return nil
|
||||
}
|
||||
// An ask that does not read at a glance is said, and shown all the same: the bound is its asker's to keep
|
||||
// (asks.Glance, issue 639), and refusing it would leave the operator unasked.
|
||||
if err := asks.Glance(a.Explanation); err != nil {
|
||||
// (occ.Glance, issue 639), and refusing it would leave the operator unasked.
|
||||
if err := occ.Glance(a.Explanation); err != nil {
|
||||
h.Logf("[messenger] %s's ask %q is a wall of text on the phone: %v", asker, a.Headline, err)
|
||||
}
|
||||
// The whole words are shown only where the sender is proven (wholeOf), so a path or an address may stand
|
||||
@@ -563,7 +572,7 @@ func (h *Holder) TakeAsk(verb, asker string, body json.RawMessage) error {
|
||||
open++
|
||||
}
|
||||
h.mu.Unlock()
|
||||
if open >= asks.MostOpen {
|
||||
if open >= occ.MostOpen {
|
||||
h.refuseAsk(asker, a, fmt.Sprintf("its asker already has %d open asks", open))
|
||||
return nil
|
||||
}
|
||||
@@ -573,7 +582,7 @@ func (h *Holder) TakeAsk(verb, asker string, body json.RawMessage) error {
|
||||
rec.State, rec.HeldSince, rec.HeldWhy = StateHeld, now, h.holdWhy(a)
|
||||
}
|
||||
if replaced != "" {
|
||||
if err := h.closeAsk(replaced, asks.OutcomeReplaced, "a newer ask about the same thing replaced it", nil, "", nil); err != nil && !errors.Is(err, ErrChanged) {
|
||||
if err := h.closeAsk(replaced, occ.OutcomeReplaced, "a newer ask about the same thing replaced it", nil, "", nil); err != nil && !errors.Is(err, ErrChanged) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -607,7 +616,7 @@ func (h *Holder) TakeAsk(verb, asker string, body json.RawMessage) error {
|
||||
|
||||
// ticketsFor is every option's ticket on every kind that can carry the ask's highest level now: an ask with an
|
||||
// approval is answered only where it could be approved (answerLevel). None when no ready channel can.
|
||||
func (h *Holder) ticketsFor(a asks.Ask) map[string]Ticket {
|
||||
func (h *Holder) ticketsFor(a oc.Ask) map[string]Ticket {
|
||||
out := map[string]Ticket{}
|
||||
for _, kind := range h.carriers(answerLevel(a)) {
|
||||
for _, o := range a.Options {
|
||||
@@ -619,7 +628,7 @@ func (h *Holder) ticketsFor(a asks.Ask) map[string]Ticket {
|
||||
|
||||
// holdWhy is why no kind can carry an ask now, per kind and in the words of the judgement that judges an answer
|
||||
// (Bench.Ready, the capabilities carriers asks for, approveHolds): never only "no channel" (ADR 0397 §3, issue 926).
|
||||
func (h *Holder) holdWhy(a asks.Ask) string {
|
||||
func (h *Holder) holdWhy(a oc.Ask) string {
|
||||
if h.Bench == nil || len(h.Bench.Kinds()) == 0 {
|
||||
return "the mesh has not said which channels exist for answers"
|
||||
}
|
||||
@@ -630,7 +639,7 @@ func (h *Holder) holdWhy(a asks.Ask) string {
|
||||
parts = append(parts, Display(k.Kind)+": "+h.Bench.Ready(k.Kind).Error())
|
||||
case !h.Bench.Promises(k.Kind, "choice") || !h.Bench.Promises(k.Kind, "exact-render"):
|
||||
parts = append(parts, Display(k.Kind)+" cannot offer a choice shown exactly as the router rendered it")
|
||||
case answerLevel(a) == asks.Approve:
|
||||
case answerLevel(a) == occ.Approve:
|
||||
if why := h.approveHolds(k.Kind, true); why != "" {
|
||||
if !strings.HasPrefix(why, Display(k.Kind)) {
|
||||
why = Display(k.Kind) + ": " + why
|
||||
@@ -646,7 +655,7 @@ func (h *Holder) holdWhy(a asks.Ask) string {
|
||||
}
|
||||
|
||||
// recordFor is the message an ask is shown on: the condition it is about when that is open here, else its own.
|
||||
func (h *Holder) recordFor(asker, key string, a asks.Ask) string {
|
||||
func (h *Holder) recordFor(asker, key string, a oc.Ask) string {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
// Shown on a condition's message only when asked by the condition's owner: another asker naming a
|
||||
@@ -720,7 +729,7 @@ func (h *Holder) releaseHeld() {
|
||||
rec, rev = *got, r
|
||||
}
|
||||
}
|
||||
if rec.State != StateHeld || !now.Before(rec.Ask.Expires) {
|
||||
if rec.State != StateHeld || !now.Before(askExpires(rec.Ask)) {
|
||||
continue // ended meanwhile; expiry is the sweep's
|
||||
}
|
||||
rec.Tickets = h.ticketsFor(rec.Ask)
|
||||
@@ -759,7 +768,7 @@ func (h *Holder) offerUnsent() {
|
||||
now := h.Now()
|
||||
for _, key := range keys {
|
||||
e := h.askOf(key)
|
||||
if e == nil || e.rec.State != StateOpen || !now.Before(e.rec.Ask.Expires) {
|
||||
if e == nil || e.rec.State != StateOpen || !now.Before(askExpires(e.rec.Ask)) {
|
||||
continue
|
||||
}
|
||||
rec := e.rec
|
||||
@@ -850,20 +859,20 @@ func (h *Holder) offerAsk(r *Record) {
|
||||
}
|
||||
|
||||
// refuseAsk says to its asker that an ask was not taken, and why.
|
||||
func (h *Holder) refuseAsk(asker string, a asks.Ask, why string) {
|
||||
func (h *Holder) refuseAsk(asker string, a oc.Ask, why string) {
|
||||
h.Logf("[messenger] refused an ask from %s (%s): %s", asker, a.ID, why)
|
||||
h.tell(asks.Warrant{Ask: a.ID, Asker: asker, About: a.About, Outcome: asks.OutcomeRefused, Words: why, At: h.Now()})
|
||||
h.tell(oc.Warrant{Ask: a.ID, Asker: asker, About: a.About, Outcome: occ.OutcomeRefused, Words: why, At: occ.FormatTime(h.Now())})
|
||||
}
|
||||
|
||||
// tell publishes a warrant, or an ask's end, to its asker alone, deduplicated by the ask.
|
||||
func (h *Holder) tell(w asks.Warrant) error {
|
||||
func (h *Holder) tell(w oc.Warrant) error {
|
||||
if h.Publish == nil {
|
||||
return errors.New("this router cannot reach the bus")
|
||||
}
|
||||
// A new message id each time (the review of 2026-10-09): an id anyone could foresee lets a publisher on
|
||||
// the same stream take it first, and the bus would drop the warrant as a duplicate. The asker acts on its
|
||||
// own record of the ask once, however many times it hears a warrant.
|
||||
err := h.Publish(asks.DecidedSubject(w.Asker), w, "warrant."+ticket())
|
||||
err := h.Publish(oc.SubjectDecided(w.Asker), w, "warrant."+ticket())
|
||||
if err != nil {
|
||||
h.Logf("[messenger] could not tell %s how its ask %s ended (%v); told again in a minute", w.Asker, w.Ask, err)
|
||||
}
|
||||
@@ -874,7 +883,7 @@ func (h *Holder) tell(w asks.Warrant) error {
|
||||
// loses: ErrChanged when another outcome was first. confirm, when given, is called once the end is recorded
|
||||
// and the asker told or not (told), before any copy is edited: the answer's confirmation where it was given
|
||||
// comes first, and never before the record stands (issue 372).
|
||||
func (h *Holder) closeAsk(key string, outcome asks.Outcome, words string, w *asks.Warrant, used string, confirm func(told bool)) error {
|
||||
func (h *Holder) closeAsk(key string, outcome string, words string, w *oc.Warrant, used string, confirm func(told bool)) error {
|
||||
e := h.askOf(key)
|
||||
if e == nil {
|
||||
return nil
|
||||
@@ -902,7 +911,7 @@ func (h *Holder) closeAsk(key string, outcome asks.Outcome, words string, w *ask
|
||||
rec.Tickets[used] = t
|
||||
}
|
||||
if w == nil {
|
||||
w = &asks.Warrant{Ask: rec.Ask.ID, Asker: rec.Asker, About: rec.Ask.About, Outcome: outcome, Words: words, At: now}
|
||||
w = &oc.Warrant{Ask: rec.Ask.ID, Asker: rec.Asker, About: rec.Ask.About, Outcome: outcome, Words: words, At: occ.FormatTime(now)}
|
||||
}
|
||||
rec.Warrant = w
|
||||
if h.Asks != nil {
|
||||
@@ -971,14 +980,14 @@ func (h *Holder) showClosed(rec AskRecord) {
|
||||
ownMessage := r.Origin == "ask"
|
||||
// The first line is how it ended, which an ask's own message shows as its title (sayOn); the rest follows.
|
||||
switch {
|
||||
case rec.Warrant != nil && rec.Warrant.Outcome == asks.OutcomeChosen:
|
||||
case rec.Warrant != nil && rec.Warrant.Outcome == occ.OutcomeChosen:
|
||||
r.Answered = chosenLine(rec.Warrant, rec.Closed.In(h.zone())) + "\n" + told
|
||||
case rec.State == string(asks.OutcomeExpired):
|
||||
case rec.State == string(occ.OutcomeExpired):
|
||||
r.Answered = "No longer answerable: nobody answered in time.\n" + strings.TrimSuffix(capital(rec.Ask.OnExpiry), ".") + "."
|
||||
case rec.State == string(asks.OutcomeReplaced) && !ownMessage:
|
||||
case rec.State == string(occ.OutcomeReplaced) && !ownMessage:
|
||||
// On a condition's message, which the newer ask takes over and shows in place.
|
||||
r.Answered = ""
|
||||
case rec.State == string(asks.OutcomeReplaced):
|
||||
case rec.State == string(occ.OutcomeReplaced):
|
||||
// Its own message: the newer ask looks for its message under its own key and opens one of its own, so
|
||||
// this one ends here, on every channel that showed it (issue 548).
|
||||
r.Answered = "Replaced by a newer question.\n" + rec.Asker + " asked again about the same thing; that question is the one to answer."
|
||||
@@ -1021,10 +1030,10 @@ func (h *Holder) endLeftovers() {
|
||||
}
|
||||
|
||||
// chosenLine is how a chosen ask ended, in the operator's words: "You chose Approve at 13:30 on Telegram."
|
||||
func chosenLine(w *asks.Warrant, at time.Time) string {
|
||||
func chosenLine(w *oc.Warrant, at time.Time) string {
|
||||
who, where := "Somebody", ""
|
||||
if w.By != nil {
|
||||
if w.By.Who == asks.Operator {
|
||||
if w.By.Who == occ.Operator {
|
||||
who = "You"
|
||||
}
|
||||
where = " on " + Display(w.By.Kind)
|
||||
@@ -1046,7 +1055,7 @@ func (h *Holder) toldLine(rec AskRecord) string {
|
||||
|
||||
// confirmChoice says, where an answer was given and in reply to it, that it was recorded: on Telegram the
|
||||
// tap's own acknowledgement (issue 372). Called only once the ask's end is recorded.
|
||||
func (h *Holder) confirmChoice(kind string, c asks.Choice, label, asker string, told bool) {
|
||||
func (h *Holder) confirmChoice(kind string, c ch.Choice, label, asker string, told bool) {
|
||||
words := "You chose " + label + ": recorded"
|
||||
switch {
|
||||
case asker == SelfAsker:
|
||||
@@ -1061,7 +1070,7 @@ func (h *Holder) confirmChoice(kind string, c asks.Choice, label, asker string,
|
||||
}
|
||||
|
||||
// confirmAgain says, in reply to the same answer given again, that it was already recorded.
|
||||
func (h *Holder) confirmAgain(kind string, c asks.Choice, label string, at time.Time) {
|
||||
func (h *Holder) confirmAgain(kind string, c ch.Choice, label string, at time.Time) {
|
||||
err := h.showOn(kind, Message{Title: "You already chose " + label + " at " + at.In(h.zone()).Format("15:04") + ": recorded.",
|
||||
Quiet: true, Reply: c.ID})
|
||||
h.record(Sent{At: h.Now(), Channel: kind, Key: "(answer)", What: "confirmed again " + label, Outcome: outcomeOf(err)})
|
||||
@@ -1073,7 +1082,7 @@ func (h *Holder) sweepAsks(now time.Time) {
|
||||
var expired, untold []string
|
||||
for k, e := range h.asks {
|
||||
switch {
|
||||
case (e.rec.State == StateOpen || e.rec.State == StateHeld) && !now.Before(e.rec.Ask.Expires):
|
||||
case (e.rec.State == StateOpen || e.rec.State == StateHeld) && !now.Before(askExpires(e.rec.Ask)):
|
||||
expired = append(expired, k)
|
||||
case e.rec.State != StateOpen && e.rec.State != StateHeld && !e.rec.Told && e.rec.Warrant != nil:
|
||||
untold = append(untold, k)
|
||||
@@ -1084,7 +1093,7 @@ func (h *Holder) sweepAsks(now time.Time) {
|
||||
sort.Strings(expired)
|
||||
for _, k := range expired {
|
||||
e := h.askOf(k)
|
||||
if err := h.closeAsk(k, asks.OutcomeExpired, "nobody answered in time: "+e.rec.Ask.OnExpiry, nil, "", nil); err != nil && !errors.Is(err, ErrChanged) {
|
||||
if err := h.closeAsk(k, occ.OutcomeExpired, "nobody answered in time: "+e.rec.Ask.OnExpiry, nil, "", nil); err != nil && !errors.Is(err, ErrChanged) {
|
||||
h.Logf("[messenger] could not end the expired ask %s: %v", k, err)
|
||||
}
|
||||
}
|
||||
@@ -1099,12 +1108,12 @@ func (h *Holder) sweepAsks(now time.Time) {
|
||||
// offers are an open ask's buttons on one kind: each option offered there, by its ticket.
|
||||
func (h *Holder) offers(rec *AskRecord, kind string) []Choice {
|
||||
var out []Choice
|
||||
if answerLevel(rec.Ask) != asks.Acknowledge && rec.Asker != SelfAsker {
|
||||
if answerLevel(rec.Ask) != occ.Acknowledge && rec.Asker != SelfAsker {
|
||||
// Rendered only while approval holds there now, by the controller's recent word.
|
||||
if h.approveHolds(kind, true) != "" {
|
||||
return nil
|
||||
}
|
||||
} else if rec.Asker == SelfAsker && answerLevel(rec.Ask) != asks.Acknowledge {
|
||||
} else if rec.Asker == SelfAsker && answerLevel(rec.Ask) != occ.Acknowledge {
|
||||
if info, held := h.Bench.Kind(kind); !held || h.rootFree(info, true) != "" {
|
||||
return nil
|
||||
}
|
||||
@@ -1165,7 +1174,7 @@ func joinOr(labels []string) string {
|
||||
|
||||
// judge is an answer to an ask's option, chosen on a kind: refused at the first failure, in words said on
|
||||
// the kind; otherwise the ask closed and the warrant published (ADR 0259 §5).
|
||||
func (h *Holder) judge(kind string, c asks.Choice, key string, root func(KindInfo) string) (string, error) {
|
||||
func (h *Holder) judge(kind string, c ch.Choice, key string, root func(KindInfo) string) (string, error) {
|
||||
e := h.askOf(key)
|
||||
if e == nil {
|
||||
return h.refuseChoice(kind, c, "it belongs to no ask this router holds")
|
||||
@@ -1178,7 +1187,7 @@ func (h *Holder) judge(kind string, c asks.Choice, key string, root func(KindInf
|
||||
}
|
||||
now := h.Now()
|
||||
t, issued := rec.Tickets[c.Ticket]
|
||||
if w := rec.Warrant; issued && rec.State == string(asks.OutcomeChosen) && w != nil && w.Option == t.Option &&
|
||||
if w := rec.Warrant; issued && rec.State == string(occ.OutcomeChosen) && w != nil && w.Option == t.Option &&
|
||||
w.By != nil && w.By.Kind == kind && w.By.Identity == c.Sender.Identity && (c.Sender.Authenticated || !h.Bench.Promises(kind, "verified-sender")) {
|
||||
// The same answer again from the same account — a second tap while the first was being confirmed, or one
|
||||
// redelivered after a restart: it is what was recorded, so it is said so, not refused as if it failed
|
||||
@@ -1195,10 +1204,10 @@ func (h *Holder) judge(kind string, c asks.Choice, key string, root func(KindInf
|
||||
return h.refuseChoice(kind, c, "the question was already answered or has ended")
|
||||
case t.Used:
|
||||
return h.refuseChoice(kind, c, "it was already chosen once")
|
||||
case !now.Before(rec.Ask.Expires):
|
||||
return h.refuseChoice(kind, c, "the question expired at "+rec.Ask.Expires.In(h.zone()).Format("15:04"))
|
||||
case !now.Before(askExpires(rec.Ask)):
|
||||
return h.refuseChoice(kind, c, "the question expired at "+askExpires(rec.Ask).In(h.zone()).Format("15:04"))
|
||||
}
|
||||
o, offered := rec.Ask.Option(t.Option)
|
||||
o, offered := occ.OptionOf(rec.Ask, t.Option)
|
||||
if !offered {
|
||||
return h.refuseChoice(kind, c, "it names an answer the question does not offer")
|
||||
}
|
||||
@@ -1206,7 +1215,7 @@ func (h *Holder) judge(kind string, c asks.Choice, key string, root func(KindInf
|
||||
if !held || !h.Bench.Promises(kind, "choice") || !h.Bench.Promises(kind, "exact-render") {
|
||||
return h.refuseChoice(kind, c, "this channel is not one the mesh holds for answers")
|
||||
}
|
||||
person := asks.Person{Who: asks.Operator, Kind: kind, Identity: c.Sender.Identity, Display: c.Sender.Display}
|
||||
person := oc.Person{Who: occ.Operator, Kind: kind, Identity: c.Sender.Identity, Display: c.Sender.Display}
|
||||
verifies := h.Bench.Promises(kind, "verified-sender")
|
||||
var id *Identity
|
||||
if verifies {
|
||||
@@ -1236,13 +1245,13 @@ func (h *Holder) judge(kind string, c asks.Choice, key string, root func(KindInf
|
||||
// ask that offers an approval needs what the approval needs, so it never ends that ask from where an
|
||||
// approval is not proven.
|
||||
level := o.Level
|
||||
if answerLevel(rec.Ask).Rank() > level.Rank() {
|
||||
if occ.Rank(answerLevel(rec.Ask)) > occ.Rank(level) {
|
||||
level = answerLevel(rec.Ask)
|
||||
}
|
||||
proofs := []string{}
|
||||
switch level {
|
||||
case asks.Acknowledge:
|
||||
case asks.Approve:
|
||||
case occ.Acknowledge:
|
||||
case occ.Approve:
|
||||
if !verifies {
|
||||
return h.refuseChoice(kind, c, Display(kind)+" cannot prove who answered, so it cannot answer a question "+
|
||||
"that asks for an approval")
|
||||
@@ -1254,18 +1263,18 @@ func (h *Holder) judge(kind string, c asks.Choice, key string, root func(KindInf
|
||||
if why := root(info); why != "" {
|
||||
return h.refuseChoice(kind, c, why)
|
||||
}
|
||||
if o.Level == asks.Approve {
|
||||
if o.Level == occ.Approve {
|
||||
proofs = append(proofs, "P1")
|
||||
}
|
||||
default:
|
||||
return h.refuseChoice(kind, c, "it needs a code from the authenticator app, which the mesh does not take yet")
|
||||
}
|
||||
w := &asks.Warrant{Ask: rec.Ask.ID, Asker: rec.Asker, About: rec.Ask.About, Outcome: asks.OutcomeChosen,
|
||||
Option: o.ID, Label: o.Label, Level: o.Level, By: &person, Channel: info.Module, Proofs: proofs, At: now,
|
||||
w := &oc.Warrant{Ask: rec.Ask.ID, Asker: rec.Asker, About: rec.Ask.About, Outcome: occ.OutcomeChosen,
|
||||
Option: o.ID, Label: o.Label, Level: o.Level, By: &person, Channel: info.Module, Proofs: proofs, At: occ.FormatTime(now),
|
||||
// The warrant answers this ask as it was taken and shown, with what each option binds (ADR 0259 §6).
|
||||
AskDigest: rec.Ask.Digest()}
|
||||
AskDigest: occ.Digest(rec.Ask)}
|
||||
confirm := func(told bool) { h.confirmChoice(kind, c, o.Label, rec.Asker, told) }
|
||||
if err := h.closeAsk(key, asks.OutcomeChosen, "", w, c.Ticket, confirm); err != nil {
|
||||
if err := h.closeAsk(key, occ.OutcomeChosen, "", w, c.Ticket, confirm); err != nil {
|
||||
if errors.Is(err, ErrChanged) {
|
||||
return h.refuseChoice(kind, c, "the question was already answered")
|
||||
}
|
||||
@@ -1274,14 +1283,14 @@ func (h *Holder) judge(kind string, c asks.Choice, key string, root func(KindInf
|
||||
return h.refuseChoice(kind, c, "it could not be recorded")
|
||||
}
|
||||
h.record(Sent{At: now, Channel: kind, Key: rec.Record, What: "answer " + o.Label, Outcome: "warrant to " + rec.Asker})
|
||||
h.Logf("[messenger] %s: a warrant for %s's ask %s", w.Says(), rec.Asker, rec.Ask.ID)
|
||||
h.Logf("[messenger] %s: a warrant for %s's ask %s", occ.Says(*w), rec.Asker, rec.Ask.ID)
|
||||
return "chosen", nil
|
||||
}
|
||||
|
||||
// refuseStranger refuses an answer from an account that is not the operator's, and tells the operator, by name
|
||||
// and number, on the account that is linked: a question's buttons reached somebody else (a forwarded message,
|
||||
// or a phone in other hands). Nothing is said to the other account.
|
||||
func (h *Holder) refuseStranger(kind string, c asks.Choice) (string, error) {
|
||||
func (h *Holder) refuseStranger(kind string, c ch.Choice) (string, error) {
|
||||
why := "this account is not linked as the operator"
|
||||
h.Logf("[messenger] an answer on %s from %s was refused: %s", kind, c.Sender.Identity, why)
|
||||
h.record(Sent{At: h.Now(), Channel: kind, Key: "(answer)", What: "answer", Outcome: "refused: " + why})
|
||||
@@ -1297,7 +1306,7 @@ func (h *Holder) refuseStranger(kind string, c asks.Choice) (string, error) {
|
||||
}
|
||||
|
||||
// refuseChoice says on the kind that an answer was not taken, and why.
|
||||
func (h *Holder) refuseChoice(kind string, c asks.Choice, why string) (string, error) {
|
||||
func (h *Holder) refuseChoice(kind string, c ch.Choice, why string) (string, error) {
|
||||
h.Logf("[messenger] an answer on %s from %s was refused: %s", kind, c.Sender.Identity, why)
|
||||
h.record(Sent{At: h.Now(), Channel: kind, Key: "(answer)", What: "answer", Outcome: "refused: " + why})
|
||||
h.showOn(kind, Message{Title: "That answer was not taken", Body: capital(why) + ". Nothing was done.",
|
||||
@@ -1317,15 +1326,15 @@ func (h *Holder) showOnHandle(kind string, m Message) (string, error) {
|
||||
if h.Publish == nil {
|
||||
return "", errors.New("this router cannot reach the bus")
|
||||
}
|
||||
out := asks.Message{Handle: newHandle(), Title: m.Title, Body: m.Body, Note: m.Note, Urgent: m.Urgent, Silent: m.Quiet, Reply: m.Reply, To: m.To,
|
||||
out := ch.Message{Handle: newHandle(), Title: m.Title, Body: m.Body, Note: m.Note, Urgent: m.Urgent, Silent: m.Quiet, Reply: m.Reply, To: m.To,
|
||||
Secret: m.Secret}
|
||||
if out.To == "" && h.Bench != nil && h.Bench.To != nil {
|
||||
out.To = h.Bench.To(kind)
|
||||
}
|
||||
for _, a := range m.Actions {
|
||||
out.Buttons = append(out.Buttons, asks.Button{Label: a.Label, Ticket: a.Token})
|
||||
out.Buttons = append(out.Buttons, ch.Button{Label: a.Label, Ticket: a.Token})
|
||||
}
|
||||
err := h.Publish(asks.ChannelSubject(asks.Show, kind), out, out.Handle)
|
||||
err := h.Publish(ch.ChannelSubject(ch.Show, kind), out, out.Handle)
|
||||
h.noteChannel(kind, err)
|
||||
return out.Handle, err
|
||||
}
|
||||
@@ -1342,7 +1351,7 @@ func (h *Holder) showOn(kind string, m Message) error {
|
||||
if h.Publish == nil {
|
||||
return errors.New("this router cannot reach the bus")
|
||||
}
|
||||
out := asks.Message{Handle: newHandle(), Title: m.Title, Body: m.Body, Note: m.Note, Urgent: m.Urgent, Silent: m.Quiet, Reply: m.Reply}
|
||||
out := ch.Message{Handle: newHandle(), Title: m.Title, Body: m.Body, Note: m.Note, Urgent: m.Urgent, Silent: m.Quiet, Reply: m.Reply}
|
||||
if h.Bench != nil && h.Bench.To != nil {
|
||||
out.To = h.Bench.To(kind)
|
||||
}
|
||||
@@ -1351,9 +1360,9 @@ func (h *Holder) showOn(kind string, m Message) error {
|
||||
}
|
||||
out.Secret = m.Secret
|
||||
for _, a := range m.Actions {
|
||||
out.Buttons = append(out.Buttons, asks.Button{Label: a.Label, Ticket: a.Token})
|
||||
out.Buttons = append(out.Buttons, ch.Button{Label: a.Label, Ticket: a.Token})
|
||||
}
|
||||
err := h.Publish(asks.ChannelSubject(asks.Send, kind), out, out.Handle)
|
||||
err := h.Publish(ch.ChannelSubject(ch.Send, kind), out, out.Handle)
|
||||
h.noteChannel(kind, err)
|
||||
return err
|
||||
}
|
||||
@@ -1387,7 +1396,7 @@ func (h *Holder) AsksNow() []AskView {
|
||||
var out []AskView
|
||||
for k, e := range h.asks {
|
||||
v := AskView{Key: k, Asker: e.rec.Asker, Headline: e.rec.Ask.Headline, State: e.rec.State,
|
||||
Opened: e.rec.Opened.UTC().Format(time.RFC3339), Expires: e.rec.Ask.Expires.UTC().Format(time.RFC3339),
|
||||
Opened: e.rec.Opened.UTC().Format(time.RFC3339), Expires: askExpires(e.rec.Ask).UTC().Format(time.RFC3339),
|
||||
Told: e.rec.Told}
|
||||
if e.rec.State == StateHeld {
|
||||
v.HeldSince, v.HeldWhy = e.rec.HeldSince.UTC().Format(time.RFC3339), e.rec.HeldWhy
|
||||
@@ -1401,8 +1410,8 @@ func (h *Holder) AsksNow() []AskView {
|
||||
}
|
||||
sort.Strings(v.Offered)
|
||||
if w := e.rec.Warrant; w != nil {
|
||||
if w.Outcome == asks.OutcomeChosen {
|
||||
v.Answer = w.Says()
|
||||
if w.Outcome == occ.OutcomeChosen {
|
||||
v.Answer = occ.Says(*w)
|
||||
} else {
|
||||
v.Answer = string(w.Outcome) + ": " + w.Words
|
||||
}
|
||||
@@ -1452,15 +1461,15 @@ func (h *Holder) sayOffered(key string, rec AskRecord) {
|
||||
|
||||
// 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) {
|
||||
func (h *Holder) sayClosed(key string, rec AskRecord, outcome string, words string) {
|
||||
if h.Emit == nil {
|
||||
return
|
||||
}
|
||||
how := string(outcome)
|
||||
switch {
|
||||
case outcome == asks.OutcomeChosen:
|
||||
case outcome == occ.OutcomeChosen:
|
||||
how = "answered"
|
||||
case outcome == asks.OutcomeCancelled && words != "its asker took it back":
|
||||
case outcome == occ.OutcomeCancelled && words != "its asker took it back":
|
||||
how = "ended by the router"
|
||||
}
|
||||
h.mu.Lock()
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
|
||||
)
|
||||
|
||||
// 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
|
||||
@@ -67,7 +67,7 @@ func TestTheRouterSaysWhenAnAskIsOfferedAndWhenItEnds(t *testing.T) {
|
||||
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 {
|
||||
if err := h.closeAsk("mesh-delivery.d-5", occ.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"
|
||||
@@ -75,7 +75,7 @@ func TestTheRouterSaysWhenAnAskIsOfferedAndWhenItEnds(t *testing.T) {
|
||||
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)
|
||||
_ = h.closeAsk("mesh-delivery.d-1", occ.OutcomeCancelled, "again", nil, "", nil)
|
||||
offered := 0
|
||||
for _, s := range got {
|
||||
if s.event == "offered" {
|
||||
|
||||
@@ -16,7 +16,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
|
||||
ch "git.novox.be/novox/mesh-sdk/go/channel"
|
||||
"github.com/nats-io/nats-server/v2/server"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
@@ -97,7 +98,7 @@ func (r *busRuntime) bucket(name string) jetstream.KeyValue {
|
||||
func (r *busRuntime) setKinds(caps map[string][]string) {
|
||||
var out []map[string]any
|
||||
for kind, c := range caps {
|
||||
out = append(out, map[string]any{"seat": asks.ChannelSeat, "kind": kind, "module": kind, "node": "test", "capabilities": c})
|
||||
out = append(out, map[string]any{"seat": ch.ChannelSeat, "kind": kind, "module": kind, "node": "test", "capabilities": c})
|
||||
}
|
||||
r.mu.Lock()
|
||||
r.kinds = out
|
||||
@@ -235,7 +236,7 @@ func (r *busRuntime) runtime(id json.RawMessage, method string, params json.RawM
|
||||
}
|
||||
return keys, err
|
||||
case "mesh/subscribe":
|
||||
if strings.HasPrefix(p.Pattern, asks.IntakeSeat) {
|
||||
if strings.HasPrefix(p.Pattern, ch.IntakeSeat) {
|
||||
r.mu.Lock()
|
||||
r.subscribed = true
|
||||
r.mu.Unlock()
|
||||
@@ -273,12 +274,12 @@ func (r *busRuntime) runtime(id json.RawMessage, method string, params json.RawM
|
||||
// forward hands the bundle what is published on the bus that it takes: an ask submitted to the seat as work,
|
||||
// a channel's word as an event.
|
||||
func (r *busRuntime) forward() {
|
||||
if _, err := r.nc.Subscribe("mesh.seat."+asks.Seat+".accept.ask.*", func(m *nats.Msg) {
|
||||
if _, err := r.nc.Subscribe("mesh.seat."+oc.Seat+".accept.ask.*", func(m *nats.Msg) {
|
||||
_, _ = r.request("mesh/work", map[string]any{"work": map[string]any{"worker": AsksWorker, "subject": m.Subject, "body": json.RawMessage(m.Data)}})
|
||||
}); err != nil {
|
||||
r.t.Fatal(err)
|
||||
}
|
||||
if _, err := r.nc.Subscribe("mesh.seat."+asks.IntakeSeat+".event.>", func(m *nats.Msg) {
|
||||
if _, err := r.nc.Subscribe("mesh.seat."+ch.IntakeSeat+".event.>", func(m *nats.Msg) {
|
||||
key := strings.Replace(strings.TrimPrefix(m.Subject, "mesh.seat."), ".event", "", 1)
|
||||
_, _ = r.request("mesh/event", map[string]any{"envelope": map[string]any{"key": key, "node": "test", "body": json.RawMessage(m.Data)}})
|
||||
}); err != nil {
|
||||
@@ -325,10 +326,10 @@ func TestAnApprovalAskIsHeldThenOfferedWhenAChannelCanCarryItOverTheBus(t *testi
|
||||
collect := func(into *[]json.RawMessage) nats.MsgHandler {
|
||||
return func(m *nats.Msg) { seen.Lock(); *into = append(*into, append([]byte(nil), m.Data...)); seen.Unlock() }
|
||||
}
|
||||
if _, err := r.nc.Subscribe(asks.DecidedSubject("mesh-delivery"), collect(&told)); err != nil {
|
||||
if _, err := r.nc.Subscribe(oc.SubjectDecided("mesh-delivery"), collect(&told)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := r.nc.Subscribe(asks.ChannelSubject(asks.Show, "telegram"), collect(&shown)); err != nil {
|
||||
if _, err := r.nc.Subscribe(ch.ChannelSubject(ch.Show, "telegram"), collect(&shown)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r.forward()
|
||||
@@ -339,13 +340,13 @@ func TestAnApprovalAskIsHeldThenOfferedWhenAChannelCanCarryItOverTheBus(t *testi
|
||||
return r.subscribed && r.taking && r.kindsRead > 0
|
||||
})
|
||||
standing, _ := json.Marshal(Standing{Ready: true, At: time.Now().UTC()})
|
||||
if err := r.nc.Publish(asks.IntakeSubject(asks.StandingWhat, "telegram"), standing); err != nil {
|
||||
if err := r.nc.Publish(ch.IntakeSubject(ch.StandingWhat, "telegram"), standing); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
time.Sleep(time.Second)
|
||||
c := &clock{t: time.Now().UTC()}
|
||||
submitted, _ := json.Marshal(heldAsk(c, "d-1"))
|
||||
if err := r.nc.Publish(asks.AskSubject("mesh-delivery"), submitted); err != nil {
|
||||
if err := r.nc.Publish(oc.AcceptSubjectAsk("mesh-delivery"), submitted); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r.until("the ask is recorded held with the reason", 20*time.Second, func() bool {
|
||||
|
||||
@@ -39,7 +39,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
|
||||
"git.novox.be/novox/mesh-sdk/go/health"
|
||||
)
|
||||
|
||||
@@ -357,7 +357,7 @@ const ConditionVerb = "condition"
|
||||
// token, which the bus grants that module alone, so it is not compared with the condition's key. A body that is
|
||||
// not a condition in the SDK's layout is logged by publisher, taken and dropped.
|
||||
func (h *Holder) takeCondition(publisher string, body []byte) {
|
||||
sc, err := asks.DecodeCondition(body)
|
||||
sc, err := occ.DecodeCondition(body)
|
||||
if err != nil {
|
||||
h.Logf("[messenger] a condition from %s was taken and dropped: %v", publisher, err)
|
||||
return
|
||||
@@ -371,7 +371,7 @@ func (h *Holder) takeCondition(publisher string, body []byte) {
|
||||
for _, a := range sc.Actions {
|
||||
c.Actions = append(c.Actions, Action{Label: a.Label, Verb: a.Verb, Machine: a.Machine, Arguments: a.Arguments})
|
||||
}
|
||||
if sc.State == asks.StateCleared {
|
||||
if sc.State == occ.StateCleared {
|
||||
h.Condition(EventCleared, c)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -10,7 +10,9 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
|
||||
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
|
||||
ch "git.novox.be/novox/mesh-sdk/go/channel"
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
@@ -23,7 +25,7 @@ import (
|
||||
type conditionBed struct {
|
||||
r *busRuntime
|
||||
mu sync.Mutex
|
||||
desk []asks.Message
|
||||
desk []ch.Message
|
||||
acks []string // the answer of the router to each work item: "" when acknowledged
|
||||
}
|
||||
|
||||
@@ -38,8 +40,8 @@ func newConditionBedWith(t *testing.T, settings string) *conditionBed {
|
||||
b := &conditionBed{r: newBusRuntime(t)}
|
||||
b.r.settings = settings
|
||||
b.r.setKinds(map[string][]string{"desktop": {"choice", "edit"}})
|
||||
if _, err := b.r.nc.Subscribe("mesh.seat."+asks.ChannelSeat+".accept.*.desktop", func(m *nats.Msg) {
|
||||
var msg asks.Message
|
||||
if _, err := b.r.nc.Subscribe("mesh.seat."+ch.ChannelSeat+".accept.*.desktop", func(m *nats.Msg) {
|
||||
var msg ch.Message
|
||||
if json.Unmarshal(m.Data, &msg) == nil {
|
||||
b.mu.Lock()
|
||||
b.desk = append(b.desk, msg)
|
||||
@@ -48,7 +50,7 @@ func newConditionBedWith(t *testing.T, settings string) *conditionBed {
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := b.r.nc.Subscribe("mesh.seat."+asks.Seat+".accept.condition.*", func(m *nats.Msg) {
|
||||
if _, err := b.r.nc.Subscribe("mesh.seat."+oc.Seat+".accept.condition.*", func(m *nats.Msg) {
|
||||
answer, err := b.r.request("mesh/work", map[string]any{"work": map[string]any{"worker": AsksWorker, "subject": m.Subject, "body": json.RawMessage(m.Data)}})
|
||||
var a struct {
|
||||
Error *struct {
|
||||
@@ -81,7 +83,7 @@ func newConditionBedWith(t *testing.T, settings string) *conditionBed {
|
||||
return b.r.subscribed
|
||||
})
|
||||
standing, _ := json.Marshal(Standing{Ready: true, At: time.Now().UTC()})
|
||||
if err := b.r.nc.Publish(asks.IntakeSubject(asks.StandingWhat, "desktop"), standing); err != nil {
|
||||
if err := b.r.nc.Publish(ch.IntakeSubject(ch.StandingWhat, "desktop"), standing); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
time.Sleep(time.Second)
|
||||
@@ -91,7 +93,7 @@ func newConditionBedWith(t *testing.T, settings string) *conditionBed {
|
||||
// forwardEvents hands the bundle the channels' word, as bus_test.go's forward does for it.
|
||||
func (b *conditionBed) forwardEvents(t *testing.T) {
|
||||
t.Helper()
|
||||
if _, err := b.r.nc.Subscribe("mesh.seat."+asks.IntakeSeat+".event.>", func(m *nats.Msg) {
|
||||
if _, err := b.r.nc.Subscribe("mesh.seat."+ch.IntakeSeat+".event.>", func(m *nats.Msg) {
|
||||
key := strings.Replace(strings.TrimPrefix(m.Subject, "mesh.seat."), ".event", "", 1)
|
||||
_, _ = b.r.request("mesh/event", map[string]any{"envelope": map[string]any{"key": key, "node": "test", "body": json.RawMessage(m.Data)}})
|
||||
}); err != nil {
|
||||
@@ -99,13 +101,13 @@ func (b *conditionBed) forwardEvents(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func (b *conditionBed) say(t *testing.T, publisher string, c asks.Condition) {
|
||||
func (b *conditionBed) say(t *testing.T, publisher string, c oc.Condition) {
|
||||
t.Helper()
|
||||
body, err := asks.EncodeCondition(c)
|
||||
body, err := occ.EncodeCondition(c)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
subject, err := asks.ConditionSubject(publisher)
|
||||
subject, err := occ.ConditionSubject(publisher)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -126,10 +128,10 @@ func (b *conditionBed) record(key string) *Record {
|
||||
return &rec
|
||||
}
|
||||
|
||||
func (b *conditionBed) told() []asks.Message {
|
||||
func (b *conditionBed) told() []ch.Message {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return append([]asks.Message(nil), b.desk...)
|
||||
return append([]ch.Message(nil), b.desk...)
|
||||
}
|
||||
|
||||
func (b *conditionBed) acknowledged(n int) bool {
|
||||
@@ -146,13 +148,13 @@ func (b *conditionBed) acknowledged(n int) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func busCondition(key, id, headline, needs string) asks.Condition {
|
||||
verdict := asks.NothingToDo
|
||||
func busCondition(key, id, headline, needs string) oc.Condition {
|
||||
verdict := occ.NothingToDo
|
||||
if needs != "" {
|
||||
verdict = asks.NeedsYou + needs
|
||||
verdict = occ.NeedsYou + needs
|
||||
}
|
||||
return asks.Condition{Key: key, Severity: asks.SeverityWarning, Subject: asks.Scope{Scope: "merge", ID: id},
|
||||
State: asks.StateRaised, Summary: "the merge waits on a check", Headline: headline, Needs: needs,
|
||||
return oc.Condition{Key: key, Severity: occ.SeverityWarning, Subject: oc.ConditionSubject{Scope: "merge", ID: id},
|
||||
State: occ.StateRaised, Summary: "the merge waits on a check", Headline: headline, Needs: needs,
|
||||
Explanation: verdict + " It waits on a check."}
|
||||
}
|
||||
|
||||
@@ -180,7 +182,7 @@ func TestAConditionSaidOnTheOneSubjectReachesTheDeskAndIsClearedOverTheBus(t *te
|
||||
t.Errorf("the work item was offered %d times, want once: it was not acknowledged", n)
|
||||
}
|
||||
// 1218/AC2: the cleared form, on the same subject, ends it.
|
||||
b.say(t, "mesh-delivery-teller", asks.Condition{Key: "merge.d-7.stalled", Subject: asks.Scope{Scope: "merge", ID: "d-7"}, State: asks.StateCleared})
|
||||
b.say(t, "mesh-delivery-teller", oc.Condition{Key: "merge.d-7.stalled", Subject: oc.ConditionSubject{Scope: "merge", ID: "d-7"}, State: occ.StateCleared})
|
||||
b.r.until("the condition is recorded cleared", 30*time.Second, func() bool {
|
||||
rec := b.record("merge.d-7.stalled")
|
||||
return rec == nil || !rec.Cleared.IsZero()
|
||||
|
||||
@@ -8,23 +8,24 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
|
||||
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
|
||||
)
|
||||
|
||||
// sdkCondition is a raised warning in mesh-sdk's layout, as a module says it on the operator channel's one subject.
|
||||
func sdkCondition(needs string) asks.Condition {
|
||||
verdict := asks.NothingToDo
|
||||
func sdkCondition(needs string) oc.Condition {
|
||||
verdict := occ.NothingToDo
|
||||
if needs != "" {
|
||||
verdict = asks.NeedsYou + needs
|
||||
verdict = occ.NeedsYou + needs
|
||||
}
|
||||
return asks.Condition{Key: "merge.d-7.stalled", Severity: asks.SeverityWarning,
|
||||
Subject: asks.Scope{Scope: "merge", ID: "d-7"}, State: asks.StateRaised, Summary: "the merge waits on a check",
|
||||
return oc.Condition{Key: "merge.d-7.stalled", Severity: occ.SeverityWarning,
|
||||
Subject: oc.ConditionSubject{Scope: "merge", ID: "d-7"}, State: occ.StateRaised, Summary: "the merge waits on a check",
|
||||
Headline: "A merge waits on a check", Needs: needs, Explanation: verdict + " It waits on a check."}
|
||||
}
|
||||
|
||||
func conditionBody(t *testing.T, c asks.Condition) json.RawMessage {
|
||||
func conditionBody(t *testing.T, c oc.Condition) json.RawMessage {
|
||||
t.Helper()
|
||||
b, err := asks.EncodeCondition(c)
|
||||
b, err := occ.EncodeCondition(c)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -81,7 +82,7 @@ func TestAClearedConditionIsClearedAsTheControllersIs(t *testing.T) { // 1217/AC
|
||||
t.Fatal(err)
|
||||
}
|
||||
settle(h, c)
|
||||
cleared := asks.Condition{Key: "merge.d-7.stalled", Subject: asks.Scope{Scope: "merge", ID: "d-7"}, State: asks.StateCleared}
|
||||
cleared := oc.Condition{Key: "merge.d-7.stalled", Subject: oc.ConditionSubject{Scope: "merge", ID: "d-7"}, State: occ.StateCleared}
|
||||
if err := h.TakeAsk("condition", "mesh-delivery-teller", conditionBody(t, cleared)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -90,7 +90,7 @@ func Check(text string) (Refusal, bool) {
|
||||
}
|
||||
|
||||
// CheckSecret says whether a text carries a secret's shape — the one class that leaves the mesh nowhere. It is
|
||||
// what an ask's whole words (asks.Ask.Whole, novox/hq issue 383) are held to: on a kind that proves who answers,
|
||||
// what an ask's whole words (oc.Ask.Whole, novox/hq issue 383) are held to: on a kind that proves who answers,
|
||||
// a path or an address is what the operator approves and may be shown, a secret never. A path is read as one:
|
||||
// a run with a slash in it is judged piece by piece between the slashes, so "/mnt/Photos_2024/Jochen" is a
|
||||
// path and not a random string (the review of 2026-10-10); the named shapes — a key block, a signed token, a
|
||||
|
||||
@@ -5,7 +5,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
|
||||
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
|
||||
)
|
||||
|
||||
// novox/hq issue 639: an ask's message reads at a glance on the phone. Its body is the asker's summary and points
|
||||
@@ -13,21 +14,21 @@ import (
|
||||
// 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 {
|
||||
return asks.Ask{ID: "kb-decision-343-0d1e2f3a", Headline: "Your agents' memory follows you to every machine",
|
||||
func decisionAsk(c *clock) oc.Ask {
|
||||
return oc.Ask{ID: "kb-decision-343-0d1e2f3a", Headline: "Your agents' memory follows you to every machine",
|
||||
Explanation: "What an agent learns in a project's memory is copied to every machine that runs the agent, so a session " +
|
||||
"anywhere starts with it. A file changed on two machines keeps both versions and warns you; secrets in memory files " +
|
||||
"are screened, though not perfectly.\n" +
|
||||
"• Only memory folders travel: links and files over 128 KiB stay where they are\n" +
|
||||
"• A conflict reaches you as a warning that needs you\n" +
|
||||
"• A deleted file is marked, and a newer change elsewhere outlives the deletion",
|
||||
Who: asks.Operator, Expires: c.now().Add(23*time.Hour + 50*time.Minute), OnExpiry: "the record stays proposed; nothing of it is in force",
|
||||
Who: occ.Operator, Expires: occ.FormatTime(c.now().Add(23*time.Hour + 50*time.Minute)), OnExpiry: "the record stays proposed; nothing of it is in force",
|
||||
About: "knowledge.decision.343",
|
||||
Details: "ADR 0343, proposed by fix-367@g14; the knowledge base's own review approved it. Fingerprint a59f 361b f60e cb5e d237 a837.\n" +
|
||||
"Read its 13 clauses with the knowledge base's get, number 343.",
|
||||
Options: []asks.Option{
|
||||
{ID: "approve", Label: "Approve", Does: "the record is accepted and its clauses are in force", Level: asks.Approve, Binds: bound("approve")},
|
||||
{ID: "decline", Label: "Decline", Does: "the record stays proposed; nothing of it is in force", Level: asks.Approve, Binds: bound("decline")},
|
||||
Options: []oc.Option{
|
||||
{ID: "approve", Label: "Approve", Does: "the record is accepted and its clauses are in force", Level: occ.Approve, Binds: bound("approve")},
|
||||
{ID: "decline", Label: "Decline", Does: "the record stays proposed; nothing of it is in force", Level: occ.Approve, Binds: bound("decline")},
|
||||
}}
|
||||
}
|
||||
|
||||
@@ -47,7 +48,7 @@ func TestAnAsksMessageIsItsSummaryWithTheRestInItsNote(t *testing.T) {
|
||||
note := strings.Split(m.Note, "\n")
|
||||
asked := c.now().In(time.UTC).Format("15:04")
|
||||
want := []string{
|
||||
"Asked by mesh-knowledge at " + asked + ". Unanswered by tomorrow " + a.Expires.In(time.UTC).Format("15:04") +
|
||||
"Asked by mesh-knowledge at " + asked + ". Unanswered by tomorrow " + askExpires(a).In(time.UTC).Format("15:04") +
|
||||
": the record stays proposed; nothing of it is in force.",
|
||||
"Approve: the record is accepted and its clauses are in force. Decline: the record stays proposed; nothing of it is in force.",
|
||||
"ADR 0343, proposed by fix-367@g14; the knowledge base's own review approved it. Fingerprint a59f 361b f60e cb5e d237 a837.",
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
|
||||
)
|
||||
|
||||
// A held ask (novox/hq ADR 0397, issue 926): an ask that breaks no rule but that no ready channel can carry now
|
||||
@@ -99,7 +99,7 @@ func TestAHeldAskExpiresAsAnyOpenAskDoes(t *testing.T) {
|
||||
stand(h, c)
|
||||
h.Tick()
|
||||
got := w.of("mesh-delivery")
|
||||
if len(got) != 1 || got[0].Outcome != asks.OutcomeExpired || store.asks["mesh-delivery.d-1"].State != string(asks.OutcomeExpired) {
|
||||
if len(got) != 1 || got[0].Outcome != occ.OutcomeExpired || store.asks["mesh-delivery.d-1"].State != string(occ.OutcomeExpired) {
|
||||
t.Fatalf("%+v %+v", got, store.asks["mesh-delivery.d-1"])
|
||||
}
|
||||
}
|
||||
@@ -113,7 +113,7 @@ func TestAHeldAskCountsTowardTheOpenAsksOfItsAsker(t *testing.T) {
|
||||
submit(t, h, "mesh-delivery", a)
|
||||
}
|
||||
got := w.of("mesh-delivery")
|
||||
if len(got) != 1 || got[0].Outcome != asks.OutcomeRefused || !strings.Contains(got[0].Words, "already has 3 open asks") {
|
||||
if len(got) != 1 || got[0].Outcome != occ.OutcomeRefused || !strings.Contains(got[0].Words, "already has 3 open asks") {
|
||||
t.Fatalf("%+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,7 +98,7 @@ type Message struct {
|
||||
Title string
|
||||
Body string
|
||||
// Note is the secondary words, after the body and the answers: who asked, what each answer does, the
|
||||
// expiry, the asker's Details (asks.Message.Note, issue 639).
|
||||
// expiry, the asker's Details (ch.Message.Note, issue 639).
|
||||
Note string
|
||||
Urgent bool
|
||||
Quiet bool // a clearing: shown without urgency
|
||||
@@ -1007,8 +1007,8 @@ func (h *Holder) askLines(rec *AskRecord, r *Record) []string {
|
||||
asker = "the router itself"
|
||||
}
|
||||
first := "Asked by " + asker + " at " + when(r.Raised, h.Now(), h.zone()) + "."
|
||||
if !rec.Ask.Expires.IsZero() && rec.Ask.OnExpiry != "" {
|
||||
first += " Unanswered by " + by(rec.Ask.Expires, h.Now(), h.zone()) + ": " + strings.TrimSuffix(rec.Ask.OnExpiry, ".") + "."
|
||||
if !askExpires(rec.Ask).IsZero() && rec.Ask.OnExpiry != "" {
|
||||
first += " Unanswered by " + by(askExpires(rec.Ask), h.Now(), h.zone()) + ": " + strings.TrimSuffix(rec.Ask.OnExpiry, ".") + "."
|
||||
}
|
||||
lines := []string{first}
|
||||
var does []string
|
||||
|
||||
@@ -30,7 +30,9 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
|
||||
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
|
||||
ch "git.novox.be/novox/mesh-sdk/go/channel"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -169,13 +171,13 @@ func (h *Holder) offerDeskUnlink(id Identity) bool {
|
||||
h.mu.Unlock()
|
||||
now := h.Now()
|
||||
until := id.ApproveFrom
|
||||
a := asks.Ask{ID: "desk-unlink-" + id.Kind + "-" + fmt.Sprint(now.Unix()), Headline: "Unlink the account linked just now?",
|
||||
a := oc.Ask{ID: "desk-unlink-" + id.Kind + "-" + fmt.Sprint(now.Unix()), Headline: "Unlink the account linked just now?",
|
||||
Explanation: fmt.Sprintf("The %s account %s (number %s) was linked as you at %s. Another account just asked "+
|
||||
"to be linked. If the linked one is not yours, unlink it here before %s; after that only that account, "+
|
||||
"or root at the router's machine, can.", Display(id.Kind), id.Display, id.Identity,
|
||||
id.Linked.In(h.zone()).Format("15:04"), until.In(h.zone()).Format("15:04")),
|
||||
Who: asks.Operator, Expires: until, OnExpiry: "the account stays linked",
|
||||
Options: []asks.Option{{ID: "unlink", Label: "Unlink it", Does: "that account stops answering as you", Level: asks.Acknowledge}}}
|
||||
Who: occ.Operator, Expires: occ.FormatTime(until), OnExpiry: "the account stays linked",
|
||||
Options: []oc.Option{{ID: "unlink", Label: "Unlink it", Does: "that account stops answering as you", Level: occ.Acknowledge}}}
|
||||
if err := h.askSelf(a, Identity{Kind: desk}, SelfAct{Do: "unlink", Kind: id.Kind, Identity: id.Identity}); err != nil {
|
||||
h.Logf("[messenger] the desk could not be asked to unlink %s: %v", id.Display, err)
|
||||
return false
|
||||
@@ -185,7 +187,7 @@ func (h *Holder) offerDeskUnlink(id Identity) bool {
|
||||
|
||||
// Link takes a link asked for on a kind: a code shown where only the operator sees it, or a refusal said on
|
||||
// the kind.
|
||||
func (h *Holder) Link(kind string, l asks.LinkAsked) string {
|
||||
func (h *Holder) Link(kind string, l ch.LinkAsked) string {
|
||||
h.warmRootFree(false) // outside the work: a question it asks reads a recent word
|
||||
h.work.Lock()
|
||||
defer h.work.Unlock()
|
||||
@@ -276,11 +278,11 @@ func (h *Holder) Link(kind string, l asks.LinkAsked) string {
|
||||
}
|
||||
|
||||
// Prove answers a code sent on a kind: the link it confirms, or why not. Never said in an event.
|
||||
func (h *Holder) Prove(kind string, c asks.Code) asks.ProofAnswer {
|
||||
func (h *Holder) Prove(kind string, c ch.Code) ch.ProofAnswer {
|
||||
h.warmRootFree(false) // outside the work: a question it asks reads a recent word
|
||||
h.work.Lock()
|
||||
defer h.work.Unlock()
|
||||
no := func(words string) asks.ProofAnswer { return asks.ProofAnswer{Accepted: false, Words: words} }
|
||||
no := func(words string) ch.ProofAnswer { return ch.ProofAnswer{Accepted: false, Words: words} }
|
||||
if c.Purpose != "" && c.Purpose != "link" {
|
||||
return no("The mesh takes no code for that yet.")
|
||||
}
|
||||
@@ -325,12 +327,12 @@ func (h *Holder) Prove(kind string, c asks.Code) asks.ProofAnswer {
|
||||
p.Confirmed = true
|
||||
p.Expires = now.Add(RelinkWaits)
|
||||
_ = h.Asks.PutLink(kind, *p)
|
||||
a := asks.Ask{ID: "relink-" + kind + "-" + fmt.Sprint(now.Unix()),
|
||||
a := oc.Ask{ID: "relink-" + kind + "-" + fmt.Sprint(now.Unix()),
|
||||
Headline: "Allow another account in your place?",
|
||||
Explanation: fmt.Sprintf("This account was unlinked from being you less than a day ago. The %s account %s "+
|
||||
"(number %s) now asks to be linked as you. If you choose Allow, it is.", Display(kind), p.Display, p.Identity),
|
||||
Who: asks.Operator, Expires: now.Add(RelinkWaits - time.Minute), OnExpiry: "nothing is linked",
|
||||
Options: []asks.Option{{ID: "allow", Label: "Allow", Does: "the other account is linked as you", Level: asks.Approve}}}
|
||||
Who: occ.Operator, Expires: occ.FormatTime(now.Add(RelinkWaits - time.Minute)), OnExpiry: "nothing is linked",
|
||||
Options: []oc.Option{{ID: "allow", Label: "Allow", Does: "the other account is linked as you", Level: occ.Approve}}}
|
||||
prior := u.Identity
|
||||
prior.ApproveFrom = prior.Linked // an account linked before: its hour passed while it was linked
|
||||
if err := h.askSelf(a, prior, SelfAct{Do: "link", Kind: kind, Identity: id.Identity, Link: &id}); err != nil {
|
||||
@@ -339,12 +341,12 @@ func (h *Holder) Prove(kind string, c asks.Code) asks.ProofAnswer {
|
||||
h.announce(Message{Title: "A new " + Display(kind) + " account asks to be linked as you",
|
||||
Body: fmt.Sprintf("%s (number %s), less than a day after an unlink. It waits for the account it replaces, "+
|
||||
"or for root at the router's machine.", p.Display, p.Identity), Urgent: true})
|
||||
return asks.ProofAnswer{Accepted: true, Words: "The code is right. Because an account was unlinked here less " +
|
||||
return ch.ProofAnswer{Accepted: true, Words: "The code is right. Because an account was unlinked here less " +
|
||||
"than a day ago, the link waits for that account's approval, or for you at the router's machine."}
|
||||
}
|
||||
h.linkNow(id, "with the code shown on the desk")
|
||||
from := now.Add(LinkedApproveAfter).In(h.zone()).Format("15:04")
|
||||
return asks.ProofAnswer{Accepted: true, Words: "Linked. You can answer acknowledgements now, and approve from " + from + "."}
|
||||
return ch.ProofAnswer{Accepted: true, Words: "Linked. You can answer acknowledgements now, and approve from " + from + "."}
|
||||
}
|
||||
|
||||
// Identities is every linked account, by kind.
|
||||
@@ -384,7 +386,7 @@ func sixDigits() (string, error) {
|
||||
}
|
||||
|
||||
// decodeLink reads a link asked for.
|
||||
func decodeLink(body json.RawMessage) (asks.LinkAsked, error) {
|
||||
var l asks.LinkAsked
|
||||
func decodeLink(body json.RawMessage) (ch.LinkAsked, error) {
|
||||
var l ch.LinkAsked
|
||||
return l, json.Unmarshal(body, &l)
|
||||
}
|
||||
|
||||
@@ -16,8 +16,9 @@ import (
|
||||
|
||||
_ "time/tzdata" // the operator's zone is known whatever this machine has installed
|
||||
|
||||
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
ch "git.novox.be/novox/mesh-sdk/go/channel"
|
||||
)
|
||||
|
||||
func errorf(format string, a ...any) error { return fmt.Errorf(format, a...) }
|
||||
@@ -492,7 +493,7 @@ func run(h *Holder, l *listening) {
|
||||
// which the bus grants that kind's holder alone.
|
||||
func kindOf(key string) (what, kind string, ok bool) {
|
||||
parts := strings.Split(key, ".")
|
||||
if len(parts) != 3 || parts[0] != asks.IntakeSeat {
|
||||
if len(parts) != 3 || parts[0] != ch.IntakeSeat {
|
||||
return "", "", false
|
||||
}
|
||||
return parts[1], parts[2], true
|
||||
@@ -507,8 +508,8 @@ func listenForChannels(h *Holder) {
|
||||
return nil
|
||||
}
|
||||
switch what {
|
||||
case asks.Chosen:
|
||||
var c asks.Choice
|
||||
case ch.Chosen:
|
||||
var c ch.Choice
|
||||
if err := json.Unmarshal(e.Body, &c); err != nil || c.Ticket == "" {
|
||||
logf("[messenger] an answer from %s could not be read: %s", kind, firstLineOf(string(e.Body)))
|
||||
return nil
|
||||
@@ -517,19 +518,19 @@ func listenForChannels(h *Holder) {
|
||||
c.ID = e.Headers["x-event-id"]
|
||||
}
|
||||
_, _ = h.Chosen(kind, c)
|
||||
case asks.Link:
|
||||
case ch.Link:
|
||||
l, err := decodeLink(e.Body)
|
||||
if err != nil {
|
||||
logf("[messenger] a link asked on %s could not be read", kind)
|
||||
return nil
|
||||
}
|
||||
h.Link(kind, l)
|
||||
case asks.FailedWhat:
|
||||
var f asks.Failed
|
||||
case ch.FailedWhat:
|
||||
var f ch.Failed
|
||||
if json.Unmarshal(e.Body, &f) == nil {
|
||||
h.Failed(kind, f)
|
||||
}
|
||||
case asks.StandingWhat:
|
||||
case ch.StandingWhat:
|
||||
// Read as this module's Standing: the bench's word, and answers-lost-at beside it (issue 384).
|
||||
var s Standing
|
||||
if json.Unmarshal(e.Body, &s) == nil {
|
||||
@@ -540,7 +541,7 @@ func listenForChannels(h *Holder) {
|
||||
return nil
|
||||
}
|
||||
for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) {
|
||||
if err := stdio.Subscribe(asks.IntakeSeat+".#", handle); err == nil {
|
||||
if err := stdio.Subscribe(ch.IntakeSeat+".#", handle); err == nil {
|
||||
logf("[messenger] listening for what the channels say")
|
||||
break
|
||||
} else {
|
||||
@@ -562,7 +563,7 @@ func takeAsks(h *Holder) {
|
||||
}
|
||||
for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) {
|
||||
if err := stdio.SeatTake(AsksWorker, handle); err == nil {
|
||||
logf("[messenger] taking the asks submitted to %s", asks.Seat)
|
||||
logf("[messenger] taking the asks submitted to %s", oc.Seat)
|
||||
break
|
||||
} else {
|
||||
logf("[messenger] not taking asks yet (%v); asking again in %s", err, wait)
|
||||
@@ -579,14 +580,14 @@ func answerProofs(h *Holder) {
|
||||
handle := func(subject string, body json.RawMessage) (any, error) {
|
||||
parts := strings.Split(subject, ".")
|
||||
kind := parts[len(parts)-1]
|
||||
var c asks.Code
|
||||
var c ch.Code
|
||||
if err := json.Unmarshal(body, &c); err != nil {
|
||||
return asks.ProofAnswer{Words: "The code could not be read."}, nil
|
||||
return ch.ProofAnswer{Words: "The code could not be read."}, nil
|
||||
}
|
||||
return h.Prove(kind, c), nil
|
||||
}
|
||||
for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) {
|
||||
if err := stdio.SeatAnswer(asks.ProofSubject(asks.CodeProof, "*"), handle); err == nil {
|
||||
if err := stdio.SeatAnswer(ch.ProofSubject(ch.CodeProof, "*"), handle); err == nil {
|
||||
logf("[messenger] answering the codes the channels are sent")
|
||||
break
|
||||
} else {
|
||||
@@ -613,7 +614,7 @@ func followKinds(h *Holder) {
|
||||
} else {
|
||||
var out []KindInfo
|
||||
for _, k := range kinds {
|
||||
if k.Seat == asks.ChannelSeat {
|
||||
if k.Seat == ch.ChannelSeat {
|
||||
out = append(out, KindInfo{Kind: k.Kind, Module: k.Module, Node: k.Node, Capabilities: k.Capabilities})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,8 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
|
||||
ch "git.novox.be/novox/mesh-sdk/go/channel"
|
||||
)
|
||||
|
||||
// The manifest says what the code does (novox/hq ADR 0259): the seats it declares — the operator's, and
|
||||
@@ -64,19 +65,19 @@ func TestItDeclaresTheSeatsOfAnAskAndHoldsTheOperatorChannel(t *testing.T) { //
|
||||
m, _ := readManifest(t)
|
||||
verbs := []string{"open", "history", "notify", "tell", "asks"}
|
||||
want := []seat{
|
||||
{Name: asks.Seat, Scope: "mesh", Serves: verbs, Accepts: []string{"ask", "cancel", "condition"}, Emits: []string{"decided"},
|
||||
{Name: oc.Seat, Scope: "mesh", Serves: verbs, Accepts: []string{"ask", "cancel", "condition"}, Emits: []string{"decided"},
|
||||
ByCaller: []string{"ask", "cancel", "condition", "decided"}, Records: []string{"asks"}},
|
||||
{Name: asks.ChannelSeat, Scope: "mesh", Kinded: true, Accepts: []string{asks.Show, asks.Edit, asks.Send}},
|
||||
{Name: asks.IntakeSeat, Scope: "mesh", Kinded: true,
|
||||
Emits: []string{asks.Chosen, asks.Link, asks.FailedWhat, asks.StandingWhat}, Proofs: []string{asks.CodeProof}},
|
||||
{Name: ch.ChannelSeat, Scope: "mesh", Kinded: true, Accepts: []string{ch.Show, ch.Edit, ch.Send}},
|
||||
{Name: ch.IntakeSeat, Scope: "mesh", Kinded: true,
|
||||
Emits: []string{ch.Chosen, ch.Link, ch.FailedWhat, ch.StandingWhat}, Proofs: []string{ch.CodeProof}},
|
||||
}
|
||||
if !reflect.DeepEqual(m.Seats, want) {
|
||||
t.Fatalf("seats %+v", m.Seats)
|
||||
}
|
||||
if len(m.Claims) != 1 || m.Claims[0].Name != asks.Seat || !reflect.DeepEqual(m.Claims[0].Serves, verbs) {
|
||||
if len(m.Claims) != 1 || m.Claims[0].Name != oc.Seat || !reflect.DeepEqual(m.Claims[0].Serves, verbs) {
|
||||
t.Fatalf("claims %+v", m.Claims)
|
||||
}
|
||||
if !reflect.DeepEqual(m.Uses, []string{asks.ChannelSeat}) {
|
||||
if !reflect.DeepEqual(m.Uses, []string{ch.ChannelSeat}) {
|
||||
t.Fatalf("uses %v", m.Uses)
|
||||
}
|
||||
if !reflect.DeepEqual(m.Consumes, []string{
|
||||
|
||||
@@ -5,6 +5,7 @@ package main
|
||||
// problem at any time, updated in place.
|
||||
|
||||
import (
|
||||
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -156,7 +157,7 @@ func TestAnAsksExpiryIsSaidWithItsDay(t *testing.T) {
|
||||
h.Zone = func() *time.Location { return time.UTC }
|
||||
c.t = now
|
||||
rec := &AskRecord{Asker: ConditionOwner}
|
||||
rec.Ask.Expires = now.Add(7 * 24 * time.Hour)
|
||||
rec.Ask.Expires = occ.FormatTime(now.Add(7 * 24 * time.Hour))
|
||||
rec.Ask.OnExpiry = "nothing is done"
|
||||
lines := strings.Join(h.askLines(rec, &Record{Raised: now}), "\n")
|
||||
if !strings.Contains(lines, "Unanswered by Fri 16 Oct 21:33: nothing is done.") {
|
||||
@@ -175,7 +176,7 @@ func TestAnEndedQuestionLeavesNoMessageOfferingItsAnswers(t *testing.T) {
|
||||
settle(h, c)
|
||||
a := ackAsk(c, "q-1")
|
||||
a.About = k.Key
|
||||
a.Expires = c.now().Add(3 * time.Hour)
|
||||
a.Expires = occ.FormatTime(c.now().Add(3 * time.Hour))
|
||||
submit(t, h, ConditionOwner, a)
|
||||
settle(h, c)
|
||||
for i := 0; i < 4*60; i++ {
|
||||
|
||||
@@ -5,7 +5,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
|
||||
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
|
||||
)
|
||||
|
||||
// openAskRecords are the records still open that show an ask as their own message.
|
||||
@@ -54,7 +55,7 @@ func TestAReplacedAsksMessageEndsWithIt(t *testing.T) {
|
||||
t.Errorf("%s: the replaced ask's message still stands: edited %v, %q %v", ch.name, edited, m.Text(), labelsOf(m))
|
||||
}
|
||||
}
|
||||
if store.asks["mesh-delivery.d-1"].State != string(asks.OutcomeReplaced) {
|
||||
if store.asks["mesh-delivery.d-1"].State != string(occ.OutcomeReplaced) {
|
||||
t.Errorf("the older ask was not replaced: %s", store.asks["mesh-delivery.d-1"].State)
|
||||
}
|
||||
}
|
||||
@@ -71,9 +72,9 @@ func TestAnAsksMessageLeftOpenAfterItsAskEndedIsEndedOnTheNextTick(t *testing.T)
|
||||
// Ended as replaced the way the router did before the fix: the ask's record only, its message untouched.
|
||||
key := "mesh-delivery.d-1"
|
||||
rec := store.asks[key]
|
||||
rec.State, rec.Closed, rec.Told = string(asks.OutcomeReplaced), c.now(), true
|
||||
rec.Warrant = &asks.Warrant{Ask: "d-1", Asker: "mesh-delivery", About: a.About, Outcome: asks.OutcomeReplaced,
|
||||
Words: "a newer ask about the same thing replaced it", At: c.now()}
|
||||
rec.State, rec.Closed, rec.Told = string(occ.OutcomeReplaced), c.now(), true
|
||||
rec.Warrant = &oc.Warrant{Ask: "d-1", Asker: "mesh-delivery", About: a.About, Outcome: occ.OutcomeReplaced,
|
||||
Words: "a newer ask about the same thing replaced it", At: occ.FormatTime(c.now())}
|
||||
store.asks[key] = rec
|
||||
h.keepAsk(key, rec, 99)
|
||||
if got := openAskRecords(h); len(got) != 1 {
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
|
||||
)
|
||||
|
||||
// Issue 384: the desk's notifier started again and the desk showed, from its own memory, nine old popups —
|
||||
@@ -129,7 +129,7 @@ func TestLostAnswersGetOnlyWhatIsOpenAndUnansweredSaidAgain(t *testing.T) {
|
||||
}
|
||||
chosen := 0
|
||||
for _, wt := range w.of(ConditionOwner) {
|
||||
if wt.Outcome == asks.OutcomeChosen {
|
||||
if wt.Outcome == occ.OutcomeChosen {
|
||||
chosen++
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,9 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
|
||||
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
|
||||
chn "git.novox.be/novox/mesh-sdk/go/channel"
|
||||
)
|
||||
|
||||
// H3: approval rests on the controller's positive, recent word that no agent can become root where the router
|
||||
@@ -67,7 +69,7 @@ func TestApprovalNeedsTheControllersWordThatRootIsFree(t *testing.T) {
|
||||
c.pass(RootFreeKept)
|
||||
stand(h, c)
|
||||
}
|
||||
if got := h.carriers(asks.Approve); len(got) != 0 {
|
||||
if got := h.carriers(occ.Approve); len(got) != 0 {
|
||||
t.Errorf("approval still carried on %v", got)
|
||||
}
|
||||
})
|
||||
@@ -184,7 +186,7 @@ func TestAStaleWordOnTheKindsPromisesNoVerifiedSender(t *testing.T) {
|
||||
}
|
||||
c.pass(KindsFresh + time.Second)
|
||||
h.Bench.Stand("telegram", Standing{Ready: true, At: c.now()})
|
||||
if h.Bench.Promises("telegram", "verified-sender") || len(h.carriers(asks.Approve)) != 0 {
|
||||
if h.Bench.Promises("telegram", "verified-sender") || len(h.carriers(occ.Approve)) != 0 {
|
||||
t.Error("a stale word still promises a verified sender")
|
||||
}
|
||||
if !h.Bench.Promises("telegram", "choice") {
|
||||
@@ -204,11 +206,11 @@ func TestALinksCodeIsSecretOnEveryPath(t *testing.T) {
|
||||
bench.Stand("telegram", Standing{Ready: true, At: c.now()})
|
||||
bench.Stand("desktop", Standing{Ready: true, At: c.now()})
|
||||
var mu sync.Mutex
|
||||
put := map[string][]asks.Message{}
|
||||
put := map[string][]chn.Message{}
|
||||
publish := func(subject string, body any, id string) error {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if m, ok := body.(asks.Message); ok {
|
||||
if m, ok := body.(chn.Message); ok {
|
||||
put[subject] = append(put[subject], m)
|
||||
}
|
||||
return nil
|
||||
@@ -217,8 +219,8 @@ func TestALinksCodeIsSecretOnEveryPath(t *testing.T) {
|
||||
Desk: &SeatChannel{Bench: bench, Role: RoleDesk, Publish: publish}, Bench: bench, Publish: publish,
|
||||
Asks: newMemAsks(), Node: "anchor", Now: c.now, Logf: t.Logf, Zone: func() *time.Location { return time.UTC }}
|
||||
h.init()
|
||||
h.Link("telegram", asks.LinkAsked{ID: "l1", At: c.now(), Sender: asks.Sender{Identity: "42", Display: "Jo", Authenticated: true}})
|
||||
shown := put[asks.ChannelSubject(asks.Send, "desktop")]
|
||||
h.Link("telegram", chn.LinkAsked{ID: "l1", At: c.now(), Sender: chn.Sender{Identity: "42", Display: "Jo", Authenticated: true}})
|
||||
shown := put[chn.ChannelSubject(chn.Send, "desktop")]
|
||||
if len(shown) != 1 || !strings.Contains(shown[0].Body, "send this code") {
|
||||
t.Fatalf("the code was not shown on the desk: %+v", put)
|
||||
}
|
||||
@@ -234,7 +236,7 @@ func TestAWarrantsMessageIdCannotBeForeseen(t *testing.T) {
|
||||
link(h, store, c, "42", c.now().Add(-2*time.Hour))
|
||||
var ids []string
|
||||
h.Publish = func(subject string, body any, id string) error {
|
||||
if _, ok := body.(asks.Warrant); ok {
|
||||
if _, ok := body.(oc.Warrant); ok {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return nil
|
||||
@@ -305,9 +307,9 @@ func TestALinkIsNotMadeWhileAnotherAccountIsLinked(t *testing.T) {
|
||||
link(h, store, c, "42", c.now().Add(-2*time.Hour))
|
||||
_, _ = h.Unlink("telegram")
|
||||
_, _ = h.Chosen("telegram", choiceOf(ticketFor(tg.sends[len(tg.sends)-1], "Unlink"), "42", c.now()))
|
||||
h.Link("telegram", asks.LinkAsked{ID: "l9", At: c.now(), Sender: asks.Sender{Identity: "666", Display: "Eve", Authenticated: true}})
|
||||
h.Link("telegram", chn.LinkAsked{ID: "l9", At: c.now(), Sender: chn.Sender{Identity: "666", Display: "Eve", Authenticated: true}})
|
||||
code := codeOf(t, dt.sends[len(dt.sends)-1])
|
||||
_ = h.Prove("telegram", asks.Code{Sender: asks.Sender{Identity: "666", Authenticated: true}, Code: code})
|
||||
_ = h.Prove("telegram", chn.Code{Sender: chn.Sender{Identity: "666", Authenticated: true}, Code: code})
|
||||
asked := tg.sends[len(tg.sends)-1]
|
||||
// Meanwhile root linked another account (allowed at the machine).
|
||||
store.ids[identityKey("telegram", "77")] = Identity{Kind: "telegram", Identity: "77", Linked: c.now(), ApproveFrom: c.now().Add(time.Hour)}
|
||||
@@ -324,7 +326,7 @@ func TestAnUnlinkedAccountDoesNotComeBackByACodeWithinADay(t *testing.T) {
|
||||
_, _ = h.Unlink("telegram")
|
||||
_, _ = h.Chosen("telegram", choiceOf(ticketFor(tg.sends[len(tg.sends)-1], "Unlink"), "42", c.now()))
|
||||
shown := len(dt.sends)
|
||||
h.Link("telegram", asks.LinkAsked{ID: "l1", At: c.now(), Sender: asks.Sender{Identity: "42", Display: "Jo", Authenticated: true}})
|
||||
h.Link("telegram", chn.LinkAsked{ID: "l1", At: c.now(), Sender: chn.Sender{Identity: "42", Display: "Jo", Authenticated: true}})
|
||||
if _, pending := store.links["telegram"]; pending {
|
||||
t.Error("a link was begun for the account unlinked an hour ago")
|
||||
}
|
||||
@@ -343,7 +345,7 @@ func TestAnUnlinkedAccountDoesNotComeBackByACodeWithinADay(t *testing.T) {
|
||||
func TestAnAccountInItsFirstHourIsUnlinkedFromTheDesk(t *testing.T) {
|
||||
h, tg, dt, c, store, w := newRouter(t)
|
||||
link(h, store, c, "666", c.now().Add(-10*time.Minute)) // an account the operator did not link
|
||||
h.Link("telegram", asks.LinkAsked{ID: "l1", At: c.now(), Sender: asks.Sender{Identity: "42", Display: "Jo", Authenticated: true}})
|
||||
h.Link("telegram", chn.LinkAsked{ID: "l1", At: c.now(), Sender: chn.Sender{Identity: "42", Display: "Jo", Authenticated: true}})
|
||||
if reply := tg.sends[len(tg.sends)-1]; !strings.Contains(reply.Body, "unlink it at your desk") {
|
||||
t.Fatalf("the sender was not told how: %q", reply.Body)
|
||||
}
|
||||
@@ -365,7 +367,7 @@ func TestAnAccountInItsFirstHourIsUnlinkedFromTheDesk(t *testing.T) {
|
||||
// Past its hour, no question goes to the desk.
|
||||
h2, tg2, dt2, c2, store2, _ := newRouter(t)
|
||||
link(h2, store2, c2, "666", c2.now().Add(-2*time.Hour))
|
||||
h2.Link("telegram", asks.LinkAsked{ID: "l1", At: c2.now(), Sender: asks.Sender{Identity: "42", Authenticated: true}})
|
||||
h2.Link("telegram", chn.LinkAsked{ID: "l1", At: c2.now(), Sender: chn.Sender{Identity: "42", Authenticated: true}})
|
||||
for _, m := range dt2.sends {
|
||||
if labelsOf(m) == "Unlink it" {
|
||||
t.Error("the desk was asked to unlink an account past its first hour")
|
||||
@@ -381,7 +383,7 @@ func TestAnAccountInItsFirstHourIsUnlinkedFromTheDesk(t *testing.T) {
|
||||
func TestNoLinkIsBegunWhileTheDeskCannotShowTheCode(t *testing.T) {
|
||||
h, tg, dt, c, store, _ := newRouter(t)
|
||||
h.Bench.Stand("desktop", Standing{Ready: false, Why: "notifications are paused on the desk", At: c.now()})
|
||||
h.Link("telegram", asks.LinkAsked{ID: "l1", At: c.now(), Sender: asks.Sender{Identity: "42", Display: "Jo", Authenticated: true}})
|
||||
h.Link("telegram", chn.LinkAsked{ID: "l1", At: c.now(), Sender: chn.Sender{Identity: "42", Display: "Jo", Authenticated: true}})
|
||||
if _, pending := store.links["telegram"]; pending {
|
||||
t.Error("a link was begun")
|
||||
}
|
||||
@@ -397,12 +399,12 @@ func TestNoLinkIsBegunWhileTheDeskCannotShowTheCode(t *testing.T) {
|
||||
// operator's link, and a restart forgets no count.
|
||||
func TestLinksAreBoundedPerAccountAndTheCountIsKept(t *testing.T) {
|
||||
h, tg, dt, c, store, _ := newRouter(t)
|
||||
eve := asks.Sender{Identity: "666", Display: "Eve", Authenticated: true}
|
||||
eve := chn.Sender{Identity: "666", Display: "Eve", Authenticated: true}
|
||||
for i := 0; i < LinksAnHour; i++ {
|
||||
h.Link("telegram", asks.LinkAsked{ID: "e" + string(rune('0'+i)), At: c.now(), Sender: eve})
|
||||
h.Link("telegram", chn.LinkAsked{ID: "e" + string(rune('0'+i)), At: c.now(), Sender: eve})
|
||||
}
|
||||
shown := len(dt.sends)
|
||||
h.Link("telegram", asks.LinkAsked{ID: "jo", At: c.now(), Sender: asks.Sender{Identity: "42", Display: "Jo", Authenticated: true}})
|
||||
h.Link("telegram", chn.LinkAsked{ID: "jo", At: c.now(), Sender: chn.Sender{Identity: "42", Display: "Jo", Authenticated: true}})
|
||||
if len(dt.sends) != shown+1 || !strings.Contains(dt.sends[len(dt.sends)-1].Body, "(number 42)") {
|
||||
t.Fatalf("a stranger's tries stopped the operator's link: %q", tg.sends[len(tg.sends)-1].Body)
|
||||
}
|
||||
@@ -412,7 +414,7 @@ func TestLinksAreBoundedPerAccountAndTheCountIsKept(t *testing.T) {
|
||||
c2.pass(c.now().Sub(c2.now()))
|
||||
stand(h2, c2)
|
||||
before := len(dt2.sends)
|
||||
h2.Link("telegram", asks.LinkAsked{ID: "e9", At: c2.now(), Sender: eve})
|
||||
h2.Link("telegram", chn.LinkAsked{ID: "e9", At: c2.now(), Sender: eve})
|
||||
if len(dt2.sends) != before || !strings.Contains(tg2.sends[len(tg2.sends)-1].Body, "Try again in an hour") {
|
||||
t.Error("a restart forgot the count")
|
||||
}
|
||||
@@ -461,7 +463,7 @@ func TestTwoAnswersAtOnceMakeOneWarrant(t *testing.T) {
|
||||
func TestAnUnlinkFromTheDeskInTheFirstHourBarsNothing(t *testing.T) {
|
||||
h, tg, dt, c, store, _ := newRouter(t)
|
||||
link(h, store, c, "42", c.now().Add(-10*time.Minute)) // the operator's own, linked just now
|
||||
h.Link("telegram", asks.LinkAsked{ID: "l1", At: c.now(), Sender: asks.Sender{Identity: "666", Display: "X", Authenticated: true}})
|
||||
h.Link("telegram", chn.LinkAsked{ID: "l1", At: c.now(), Sender: chn.Sender{Identity: "666", Display: "X", Authenticated: true}})
|
||||
asked := dt.sends[len(dt.sends)-1]
|
||||
ch := choiceOf(ticketFor(asked, "Unlink it"), "g14", c.now()) // a click nobody can prove was the operator's
|
||||
ch.Sender.Authenticated = false
|
||||
@@ -477,7 +479,7 @@ func TestAnUnlinkFromTheDeskInTheFirstHourBarsNothing(t *testing.T) {
|
||||
// The operator sends /start again: a code is shown at once, and the code links without waiting for anybody.
|
||||
c.pass(time.Minute)
|
||||
stand(h, c)
|
||||
h.Link("telegram", asks.LinkAsked{ID: "l2", At: c.now(), Sender: asks.Sender{Identity: "42", Display: "Jo", Authenticated: true}})
|
||||
h.Link("telegram", chn.LinkAsked{ID: "l2", At: c.now(), Sender: chn.Sender{Identity: "42", Display: "Jo", Authenticated: true}})
|
||||
code := ""
|
||||
for _, m := range dt.sends {
|
||||
if strings.Contains(m.Body, "number 42") && strings.Contains(m.Body, "send this code") {
|
||||
@@ -487,7 +489,7 @@ func TestAnUnlinkFromTheDeskInTheFirstHourBarsNothing(t *testing.T) {
|
||||
if code == "" {
|
||||
t.Fatalf("no code was shown to relink the operator's account; said %q", tg.sends[len(tg.sends)-1].Body)
|
||||
}
|
||||
if a := h.Prove("telegram", asks.Code{Sender: asks.Sender{Identity: "42", Authenticated: true}, Code: code, Purpose: "link"}); !a.Accepted ||
|
||||
if a := h.Prove("telegram", chn.Code{Sender: chn.Sender{Identity: "42", Authenticated: true}, Code: code, Purpose: "link"}); !a.Accepted ||
|
||||
!strings.Contains(a.Words, "Linked") {
|
||||
t.Fatalf("the operator's code was not taken at once: %+v", a)
|
||||
}
|
||||
@@ -547,7 +549,7 @@ func TestTheRootFreeWordIsNotAskedWhileTheWorkIsHeld(t *testing.T) {
|
||||
<-asking
|
||||
linked := make(chan struct{})
|
||||
go func() {
|
||||
h.Link("telegram", asks.LinkAsked{ID: "l9", At: c.now(), Sender: asks.Sender{Identity: "77", Authenticated: true}})
|
||||
h.Link("telegram", chn.LinkAsked{ID: "l9", At: c.now(), Sender: chn.Sender{Identity: "77", Authenticated: true}})
|
||||
close(linked)
|
||||
}()
|
||||
select {
|
||||
|
||||
@@ -24,7 +24,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
ch "git.novox.be/novox/mesh-sdk/go/channel"
|
||||
)
|
||||
|
||||
// StandingFresh is how long a channel's word on whether it can send is believed: a channel says it at
|
||||
@@ -277,16 +277,16 @@ func (c *SeatChannel) putOn(kind, handle string, m Message, edit bool) error {
|
||||
if c.Publish == nil {
|
||||
return errors.New("this router cannot reach the bus")
|
||||
}
|
||||
verb := asks.Send
|
||||
verb := ch.Send
|
||||
switch {
|
||||
case edit:
|
||||
verb = asks.Edit
|
||||
verb = ch.Edit
|
||||
case len(m.Actions) > 0:
|
||||
verb = asks.Show
|
||||
verb = ch.Show
|
||||
}
|
||||
// Secret travels whatever the path: a link's code is kept by no channel (the review of 2026-10-09 found it
|
||||
// dropped here, where the desk's own role sends).
|
||||
out := asks.Message{Handle: handle, Title: m.Title, Body: m.Body, Note: m.Note, Urgent: m.Urgent, Silent: m.Quiet, Reply: m.Reply,
|
||||
out := ch.Message{Handle: handle, Title: m.Title, Body: m.Body, Note: m.Note, Urgent: m.Urgent, Silent: m.Quiet, Reply: m.Reply,
|
||||
Secret: m.Secret}
|
||||
if c.Bench.To != nil {
|
||||
out.To = c.Bench.To(kind)
|
||||
@@ -295,9 +295,9 @@ func (c *SeatChannel) putOn(kind, handle string, m Message, edit bool) error {
|
||||
out.To = m.To
|
||||
}
|
||||
for _, a := range m.Actions {
|
||||
out.Buttons = append(out.Buttons, asks.Button{Label: a.Label, Ticket: a.Token})
|
||||
out.Buttons = append(out.Buttons, ch.Button{Label: a.Label, Ticket: a.Token})
|
||||
}
|
||||
return c.Publish(asks.ChannelSubject(verb, kind), out, handle+"."+fmt.Sprint(time.Now().UnixNano()))
|
||||
return c.Publish(ch.ChannelSubject(verb, kind), out, handle+"."+fmt.Sprint(time.Now().UnixNano()))
|
||||
}
|
||||
|
||||
func newHandle() string {
|
||||
|
||||
@@ -21,7 +21,8 @@ import (
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
|
||||
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
|
||||
)
|
||||
|
||||
// SelfAsker is the name the router asks its own questions under.
|
||||
@@ -40,9 +41,9 @@ type SelfAct struct {
|
||||
Link *Identity `json:"link,omitempty"` // the account a link makes the operator's
|
||||
}
|
||||
|
||||
// act is what an option of the router's own ask binds: its named fields (asks.ActDigest).
|
||||
func (a SelfAct) act() asks.Act {
|
||||
return asks.Act{"do": a.Do, "kind": a.Kind, "identity": a.Identity}
|
||||
// act is what an option of the router's own ask binds: its named fields (occ.ActDigest).
|
||||
func (a SelfAct) act() occ.Act {
|
||||
return occ.Act{"do": a.Do, "kind": a.Kind, "identity": a.Identity}
|
||||
}
|
||||
|
||||
// Unlinked is the last account unlinked on a kind, kept a day so a relink is asked of it.
|
||||
@@ -57,9 +58,9 @@ type Unlinked struct {
|
||||
}
|
||||
|
||||
// askSelf puts a question of the router's own to one account, on its kind, and keeps it like any ask.
|
||||
func (h *Holder) askSelf(a asks.Ask, only Identity, act SelfAct) error {
|
||||
func (h *Holder) askSelf(a oc.Ask, only Identity, act SelfAct) error {
|
||||
// Its options bind the act, as any asker's do (ADR 0259 §6): the warrant then performs this act alone.
|
||||
binds, err := asks.ActDigest(act.act())
|
||||
binds, err := occ.ActDigest(act.act())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -103,13 +104,13 @@ func (h *Holder) askSelf(a asks.Ask, only Identity, act SelfAct) error {
|
||||
}
|
||||
|
||||
// selfAct does what the router's own ask decided.
|
||||
func (h *Holder) selfAct(rec AskRecord, w asks.Warrant) {
|
||||
if rec.Act == nil || w.Outcome != asks.OutcomeChosen {
|
||||
func (h *Holder) selfAct(rec AskRecord, w oc.Warrant) {
|
||||
if rec.Act == nil || w.Outcome != occ.OutcomeChosen {
|
||||
return
|
||||
}
|
||||
o, err := w.For(SelfAsker, rec.Ask)
|
||||
o, err := occ.For(w, SelfAsker, rec.Ask)
|
||||
if err == nil {
|
||||
err = o.Performs(rec.Act.act())
|
||||
err = occ.Performs(o, rec.Act.act())
|
||||
}
|
||||
if err != nil {
|
||||
h.Logf("[messenger] REFUSED a warrant for its own ask %s: %v", rec.Ask.ID, err)
|
||||
@@ -117,7 +118,7 @@ func (h *Holder) selfAct(rec AskRecord, w asks.Warrant) {
|
||||
}
|
||||
switch rec.Act.Do {
|
||||
case "unlink":
|
||||
if o.Level == asks.Acknowledge {
|
||||
if o.Level == occ.Acknowledge {
|
||||
// Asked at the desk: only an account still in its first hour, which cannot approve yet, is unlinked
|
||||
// this way (ADR 0259 §7, the review of 2026-10-09). Past its hour it decides for itself.
|
||||
id := h.identity(rec.Act.Kind, rec.Act.Identity)
|
||||
@@ -150,11 +151,11 @@ func (h *Holder) Unlink(kind string) (map[string]any, error) {
|
||||
}
|
||||
id := ids[0]
|
||||
now := h.Now()
|
||||
a := asks.Ask{ID: "unlink-" + kind + "-" + fmt.Sprint(now.Unix()), Headline: "Unlink this account?",
|
||||
a := oc.Ask{ID: "unlink-" + kind + "-" + fmt.Sprint(now.Unix()), Headline: "Unlink this account?",
|
||||
Explanation: fmt.Sprintf("Unlinking this %s account (%s, number %s) was asked. If you choose Unlink, answers "+
|
||||
"from here stop counting as yours until an account is linked again.", Display(kind), id.Display, id.Identity),
|
||||
Who: asks.Operator, Expires: now.Add(time.Hour), OnExpiry: "the account stays linked",
|
||||
Options: []asks.Option{{ID: "unlink", Label: "Unlink", Does: "this account stops answering as you", Level: asks.Approve}}}
|
||||
Who: occ.Operator, Expires: occ.FormatTime(now.Add(time.Hour)), OnExpiry: "the account stays linked",
|
||||
Options: []oc.Option{{ID: "unlink", Label: "Unlink", Does: "this account stops answering as you", Level: occ.Approve}}}
|
||||
if err := h.askSelf(a, id, SelfAct{Do: "unlink", Kind: kind, Identity: id.Identity}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -302,7 +303,7 @@ func (h *Holder) endSelfAsks(why string) int {
|
||||
sort.Strings(keys)
|
||||
n := 0
|
||||
for _, k := range keys {
|
||||
if err := h.closeAsk(k, asks.OutcomeCancelled, why, nil, "", nil); err == nil {
|
||||
if err := h.closeAsk(k, occ.OutcomeCancelled, why, nil, "", nil); err == nil {
|
||||
n++
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
|
||||
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
|
||||
)
|
||||
|
||||
// novox/hq issue 383: a settings proposal's values are what the operator approves, so where the sender is proven
|
||||
@@ -14,7 +15,7 @@ import (
|
||||
|
||||
// proposalAsk is a proposal as the controller composes it: the explanation with its values masked, the whole
|
||||
// with them whole, and Details with the fingerprint and where to read it, which the note shows.
|
||||
func proposalAsk(c *clock, id string) asks.Ask {
|
||||
func proposalAsk(c *clock, id string) oc.Ask {
|
||||
a := heldAsk(c, id)
|
||||
a.Headline = "Set mounts on shanks?"
|
||||
a.Options = a.Options[:2]
|
||||
@@ -145,7 +146,7 @@ func TestAnAskWhoseWholeWordsCarryASecretIsRefused(t *testing.T) {
|
||||
submit(t, h, "mesh-controller", a)
|
||||
settle(h, c)
|
||||
got := w.of("mesh-controller")
|
||||
if len(tg.sends) != 0 || len(got) != 1 || got[0].Outcome != asks.OutcomeRefused || !strings.Contains(got[0].Words, "whole words carry secret") {
|
||||
if len(tg.sends) != 0 || len(got) != 1 || got[0].Outcome != occ.OutcomeRefused || !strings.Contains(got[0].Words, "whole words carry secret") {
|
||||
t.Errorf("%q: sent %d, told %+v", secret, len(tg.sends), got)
|
||||
}
|
||||
if _, kept := store.asks["mesh-controller.s-4"]; kept {
|
||||
|
||||
@@ -3,6 +3,8 @@ module messenger
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-contracts/go/seats/operatorchannel v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128
|
||||
github.com/nats-io/nats-server/v2 v2.11.17
|
||||
github.com/nats-io/nats.go v1.54.0
|
||||
|
||||
@@ -18,8 +18,9 @@ import (
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
|
||||
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
|
||||
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/handacts"
|
||||
@@ -121,13 +122,13 @@ func eventID() string {
|
||||
}
|
||||
|
||||
// sayer raises and clears the keeper's conditions through the condition keeper, as every module says them.
|
||||
func sayer(js jetstream.JetStream) func(asks.Condition) error {
|
||||
return func(c asks.Condition) error {
|
||||
subject, err := asks.ConditionSubject(keeper.Module)
|
||||
func sayer(js jetstream.JetStream) func(oc.Condition) error {
|
||||
return func(c oc.Condition) error {
|
||||
subject, err := occ.ConditionSubject(keeper.Module)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := asks.EncodeCondition(c)
|
||||
data, err := occ.EncodeCondition(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -200,14 +201,14 @@ func runKeeper(ctx context.Context, getenv func(string) string, logOut io.Writer
|
||||
_, err := recorder.Record(handacts.Request{What: what, Why: why, Cause: cause})
|
||||
return err
|
||||
},
|
||||
Warrant: func(id string) (*asks.Ask, *asks.Warrant, error) {
|
||||
Warrant: func(id string) (*oc.Ask, *oc.Warrant, error) {
|
||||
e, err := conn.Ask(asksBucket, id)
|
||||
if err != nil || e == nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
var rec struct {
|
||||
Ask *asks.Ask `json:"ask"`
|
||||
Warrant *asks.Warrant `json:"warrant"`
|
||||
Ask *oc.Ask `json:"ask"`
|
||||
Warrant *oc.Warrant `json:"warrant"`
|
||||
}
|
||||
if err := json.Unmarshal(e.Value, &rec); err != nil {
|
||||
return nil, nil, err
|
||||
|
||||
@@ -18,7 +18,8 @@ import (
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
|
||||
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/process"
|
||||
|
||||
@@ -137,8 +138,8 @@ func TestTheBusModuleRunsWhole(t *testing.T) {
|
||||
var mu sync.Mutex
|
||||
view := keeper.FirstView{Revision: 1}
|
||||
var acts []string
|
||||
var ask *asks.Ask
|
||||
var warrant *asks.Warrant
|
||||
var ask *oc.Ask
|
||||
var warrant *oc.Warrant
|
||||
newKeeper := func() *keeper.Keeper {
|
||||
return &keeper.Keeper{Bus: keeper.JetStream{JS: js, Snap: bussnap.Into(nc, snapshots)}, Store: store,
|
||||
Emit: streamEmitter(js, account), Say: sayer(js),
|
||||
@@ -149,7 +150,7 @@ func TestTheBusModuleRunsWhole(t *testing.T) {
|
||||
acts = append(acts, cause+": "+what)
|
||||
return nil
|
||||
},
|
||||
Warrant: func(string) (*asks.Ask, *asks.Warrant, error) { mu.Lock(); defer mu.Unlock(); return ask, warrant, nil },
|
||||
Warrant: func(string) (*oc.Ask, *oc.Warrant, error) { mu.Lock(); defer mu.Unlock(); return ask, warrant, nil },
|
||||
Logf: t.Logf}
|
||||
}
|
||||
run := func(k *keeper.Keeper) func() {
|
||||
@@ -328,8 +329,8 @@ func TestTheBusModuleRunsWhole(t *testing.T) {
|
||||
t.Fatal("dropped before the operator chose")
|
||||
}
|
||||
mu.Lock()
|
||||
warrant = &asks.Warrant{Ask: a.ID, Asker: keeper.Module, Outcome: asks.OutcomeChosen, By: &asks.Person{Who: asks.Operator},
|
||||
Option: "go", Label: "Remove", Level: asks.Destroy, At: time.Now(), AskDigest: a.Digest()}
|
||||
warrant = &oc.Warrant{Ask: a.ID, Asker: keeper.Module, Outcome: occ.OutcomeChosen, By: &oc.Person{Who: occ.Operator},
|
||||
Option: "go", Label: "Remove", Level: occ.Destroy, At: occ.FormatTime(time.Now()), AskDigest: occ.Digest(a)}
|
||||
mu.Unlock()
|
||||
k.ActOnRequests()
|
||||
eventually(t, "dropped m drafts", saidHas("dropped m drafts"))
|
||||
|
||||
@@ -8,8 +8,9 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
|
||||
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
|
||||
"nats-tools/internal/keeper"
|
||||
)
|
||||
@@ -24,7 +25,7 @@ import (
|
||||
type requestStore struct {
|
||||
Put func(key string, r keeper.Request) error
|
||||
Get func(key string) (*keeper.Request, error)
|
||||
Ask func(a asks.Ask) error
|
||||
Ask func(a oc.Ask) error
|
||||
Now func() time.Time
|
||||
Wait func(d time.Duration)
|
||||
}
|
||||
@@ -42,7 +43,7 @@ func RequestsInState() requestStore {
|
||||
var r keeper.Request
|
||||
return &r, json.Unmarshal(e.Value, &r)
|
||||
},
|
||||
Ask: func(a asks.Ask) error { return stdio.SeatPublish(asks.AskSubject(keeper.Module), a, a.ID) },
|
||||
Ask: func(a oc.Ask) error { return stdio.SeatPublish(oc.AcceptSubjectAsk(keeper.Module), a, a.ID) },
|
||||
Now: func() time.Time { return time.Now().UTC() },
|
||||
Wait: time.Sleep,
|
||||
}
|
||||
@@ -55,22 +56,22 @@ func requestID(now time.Time) string {
|
||||
}
|
||||
|
||||
// askFor is the operator's ask for a recreate or a drop of one entry, its approve option binding exactly that act.
|
||||
func askFor(r keeper.Request, now time.Time) (asks.Ask, error) {
|
||||
binds, err := asks.ActDigest(r.Act())
|
||||
func askFor(r keeper.Request, now time.Time) (oc.Ask, error) {
|
||||
binds, err := occ.ActDigest(r.Act())
|
||||
if err != nil {
|
||||
return asks.Ask{}, err
|
||||
return oc.Ask{}, err
|
||||
}
|
||||
what := map[string]string{keeper.VerbDrop: "remove", keeper.VerbRecreate: "rebuild"}[r.Verb]
|
||||
name := keeper.BusName(r.Module, r.Local)
|
||||
return asks.Ask{ID: "bus-" + r.ID, Who: asks.Operator, Expires: now.Add(24 * time.Hour),
|
||||
return oc.Ask{ID: "bus-" + r.ID, Who: occ.Operator, Expires: occ.FormatTime(now.Add(24 * time.Hour)),
|
||||
OnExpiry: "nothing is removed and the entry stays as it is",
|
||||
About: keeper.ConditionKey(map[string]string{keeper.VerbDrop: "orphaned", keeper.VerbRecreate: "drifted"}[r.Verb], r.Module, r.Local),
|
||||
Headline: fmt.Sprintf("%s's kept data %s: %s it?", r.Module, r.Local, what),
|
||||
Explanation: fmt.Sprintf("%s asks to %s %s on the bus, %s. A snapshot of it is taken first; its data is lost from the bus.",
|
||||
r.Caller, what, name, r.Why),
|
||||
Options: []asks.Option{
|
||||
{ID: "go", Label: strings.ToUpper(what[:1]) + what[1:], Does: fmt.Sprintf("%s %s after a snapshot", what, name), Level: asks.Destroy, Binds: binds},
|
||||
{ID: "keep", Label: "Keep it", Does: "nothing is removed", Level: asks.Acknowledge},
|
||||
Options: []oc.Option{
|
||||
{ID: "go", Label: strings.ToUpper(what[:1]) + what[1:], Does: fmt.Sprintf("%s %s after a snapshot", what, name), Level: occ.Destroy, Binds: binds},
|
||||
{ID: "keep", Label: "Keep it", Does: "nothing is removed", Level: occ.Acknowledge},
|
||||
}}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -5,8 +5,9 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
|
||||
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
|
||||
"nats-tools/internal/keeper"
|
||||
)
|
||||
@@ -16,7 +17,7 @@ import (
|
||||
// first, binding exactly that act, and is not acted on by the tool.
|
||||
func TestAPersonsRequestIsHandedToTheKeeperAsTheProvedCaller(t *testing.T) {
|
||||
held := map[string]keeper.Request{}
|
||||
var raised []asks.Ask
|
||||
var raised []oc.Ask
|
||||
rs := requestStore{
|
||||
Put: func(key string, r keeper.Request) error {
|
||||
if r.Verb == keeper.VerbCreate {
|
||||
@@ -33,7 +34,7 @@ func TestAPersonsRequestIsHandedToTheKeeperAsTheProvedCaller(t *testing.T) {
|
||||
}
|
||||
return &r, nil
|
||||
},
|
||||
Ask: func(a asks.Ask) error { raised = append(raised, a); return nil },
|
||||
Ask: func(a oc.Ask) error { raised = append(raised, a); return nil },
|
||||
Now: func() time.Time { return time.Date(2026, 11, 4, 12, 0, 0, 0, time.UTC) },
|
||||
Wait: func(time.Duration) {},
|
||||
}
|
||||
@@ -57,8 +58,8 @@ func TestAPersonsRequestIsHandedToTheKeeperAsTheProvedCaller(t *testing.T) {
|
||||
if len(raised) != 1 || r.Ask != raised[0].ID || !r.Done.IsZero() {
|
||||
t.Fatalf("raised %+v, held %+v: the ask raised, the request waiting for it", raised, r)
|
||||
}
|
||||
binds, _ := asks.ActDigest(asks.Act{"verb": "drop", "module": "m", "local": "old"})
|
||||
if o, _ := raised[0].Option("go"); o.Level != asks.Destroy || o.Binds != binds || !strings.Contains(raised[0].Explanation, "snapshot") {
|
||||
binds, _ := occ.ActDigest(occ.Act{"verb": "drop", "module": "m", "local": "old"})
|
||||
if o, _ := occ.OptionOf(raised[0], "go"); o.Level != occ.Destroy || o.Binds != binds || !strings.Contains(raised[0].Explanation, "snapshot") {
|
||||
t.Errorf("the ask is %+v: its option destroys exactly this drop, after a snapshot", raised[0])
|
||||
}
|
||||
if _, err := tools["nats_drop"].Run(map[string]any{"module": "m", "local": "old"}); err == nil || !strings.Contains(err.Error(), "says why") {
|
||||
|
||||
@@ -3,6 +3,9 @@ module nats-tools
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-contracts/go/seats/meshbroker v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-contracts/go/seats/operatorchannel v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8
|
||||
github.com/klauspost/compress v1.20.0
|
||||
github.com/nats-io/nats-server/v2 v2.11.17
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
|
||||
)
|
||||
|
||||
// FillPass reads the fill of every entry that refuses when full (ADR 0595 §6), raising `full` at the raise mark and
|
||||
@@ -52,7 +52,7 @@ func (k *Keeper) FillPass() {
|
||||
case !m.Full && most >= raise:
|
||||
m.Full = true
|
||||
k.emit(EventFull, m.Plan, held.Revision, fmt.Sprintf("%d%% of its cap, past the raise mark %d%%", most, raise), nil, nil)
|
||||
k.raise(conditionFull, module, local, asks.SeverityWarning, "Kept data is filling up",
|
||||
k.raise(conditionFull, module, local, occ.SeverityWarning, "Kept data is filling up",
|
||||
"a larger cap in the module's manifest, or data removed",
|
||||
fmt.Sprintf("%s of %s holds %d%% of its cap and refuses new data when full; it is cleared below %d%%", local, module, most, clear))
|
||||
case m.Full && most < clear:
|
||||
|
||||
@@ -10,7 +10,8 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
|
||||
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/membership"
|
||||
)
|
||||
@@ -184,12 +185,12 @@ type Keeper struct {
|
||||
Store Store
|
||||
// Emit says one of the bus module's events; Say raises or clears a condition through the condition keeper.
|
||||
Emit func(event string, body Event) error
|
||||
Say func(asks.Condition) error
|
||||
Say func(oc.Condition) error
|
||||
// View asks the indexer's indexer_bus_state.
|
||||
View func() (FirstView, error)
|
||||
// Act records a hand act through the seat mesh-hand-acts; Warrant reads the router's record of an ask.
|
||||
Act func(what, why, cause string) error
|
||||
Warrant func(askID string) (*asks.Ask, *asks.Warrant, error)
|
||||
Warrant func(askID string) (*oc.Ask, *oc.Warrant, error)
|
||||
Now func() time.Time
|
||||
Logf func(string, ...any)
|
||||
// Raise and Clear are the fill marks in percent (ADR 0595 §6): settings of the bus module, 75 and 70 by default.
|
||||
@@ -338,7 +339,7 @@ func (k *Keeper) Heard(a Accepted) {
|
||||
m.Orphaned = true
|
||||
held.Made[local] = m
|
||||
k.emit(EventOrphaned, m.Plan, held.Revision, "no accepted manifest of "+a.Module+" asks for it now", nil, nil)
|
||||
k.raise(conditionOrphaned, a.Module, local, asks.SeverityWarning, "Kept data no manifest asks for",
|
||||
k.raise(conditionOrphaned, a.Module, local, occ.SeverityWarning, "Kept data no manifest asks for",
|
||||
"the operator's drop of it, or a manifest asking for it again",
|
||||
fmt.Sprintf("%s of %s is on the bus and no accepted manifest asks for it: it goes only by the bus module's drop, on your warrant (nats_drop), or a manifest asking for it again keeps it", BusName(a.Module, local), a.Module))
|
||||
}
|
||||
@@ -353,7 +354,7 @@ func (k *Keeper) makeEntry(held *Asked, e catalogue.BusStateEntry) {
|
||||
if err != nil {
|
||||
if m.Refused != err.Error() {
|
||||
k.emit(EventRefused, Plan{Module: module, Local: e.Name, Storage: e.Storage, Template: e.Template}, held.Revision, err.Error(), nil, nil)
|
||||
k.raise(conditionRefused, module, e.Name, asks.SeverityWarning, "The bus module refused to create kept data",
|
||||
k.raise(conditionRefused, module, e.Name, occ.SeverityWarning, "The bus module refused to create kept data",
|
||||
"a manifest that asks for it in a form the bus module makes",
|
||||
fmt.Sprintf("%s of %s was not created: %v. The module gets no binding for it and fails at its start, naming it", e.Name, module, err))
|
||||
}
|
||||
@@ -394,7 +395,7 @@ func (k *Keeper) ensure(held *Asked, m *Made) {
|
||||
}
|
||||
m.Refused, m.Waiting = fmt.Sprintf("%s: %v", o.Stream(), err), ""
|
||||
k.emit(EventRefused, m.Plan, held.Revision, m.Refused, asked, nil)
|
||||
k.raise(conditionRefused, held.Module, m.Plan.Local, asks.SeverityWarning, "The bus module refused to create kept data",
|
||||
k.raise(conditionRefused, held.Module, m.Plan.Local, occ.SeverityWarning, "The bus module refused to create kept data",
|
||||
"a look at why the bus refused it",
|
||||
fmt.Sprintf("%s of %s was not created: %s. The module gets no binding for it and fails at its start, naming it", m.Plan.Local, held.Module, m.Refused))
|
||||
return
|
||||
@@ -430,7 +431,7 @@ func (k *Keeper) ensure(held *Asked, m *Made) {
|
||||
case drift != "" && drift != m.Drift:
|
||||
m.Drift = drift
|
||||
k.emit(EventDrifted, m.Plan, held.Revision, drift, asked, holds)
|
||||
k.raise(conditionDrifted, held.Module, m.Plan.Local, asks.SeverityWarning, "Kept data differs from what was asked",
|
||||
k.raise(conditionDrifted, held.Module, m.Plan.Local, occ.SeverityWarning, "Kept data differs from what was asked",
|
||||
"a decision: change the manifest back, or the bus module's recreate on your warrant",
|
||||
fmt.Sprintf("%s of %s differs from what its manifest asks, in a way that does not only grow: %s", m.Plan.Local, held.Module, drift))
|
||||
case drift == "" && m.Drift != "":
|
||||
@@ -529,10 +530,10 @@ func (k *Keeper) raise(kind, module, local, severity, headline, needs, explanati
|
||||
if k.Say == nil {
|
||||
return
|
||||
}
|
||||
c := asks.Condition{Key: ConditionKey(kind, module, local), Subject: asks.Scope{Scope: "module", ID: module},
|
||||
State: asks.StateRaised, Severity: severity, Headline: headline, Needs: needs,
|
||||
c := oc.Condition{Key: ConditionKey(kind, module, local), Subject: oc.ConditionSubject{Scope: "module", ID: module},
|
||||
State: occ.StateRaised, Severity: severity, Headline: headline, Needs: needs,
|
||||
Summary: explanation,
|
||||
Explanation: asks.NeedsYou + needs + ". " + explanation,
|
||||
Explanation: occ.NeedsYou + needs + ". " + explanation,
|
||||
Resolved: "cleared by the bus module once it no longer holds"}
|
||||
if err := k.Say(c); err != nil {
|
||||
k.logf("the condition %s cannot be raised: %v", c.Key, err)
|
||||
@@ -543,7 +544,7 @@ func (k *Keeper) clear(kind, module, local string) {
|
||||
if k.Say == nil {
|
||||
return
|
||||
}
|
||||
c := asks.Condition{Key: ConditionKey(kind, module, local), Subject: asks.Scope{Scope: "module", ID: module}, State: asks.StateCleared}
|
||||
c := oc.Condition{Key: ConditionKey(kind, module, local), Subject: oc.ConditionSubject{Scope: "module", ID: module}, State: occ.StateCleared}
|
||||
if err := k.Say(c); err != nil {
|
||||
k.logf("the condition %s cannot be cleared: %v", c.Key, err)
|
||||
}
|
||||
|
||||
@@ -11,7 +11,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
|
||||
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
@@ -136,7 +137,7 @@ func (s *said) emit(event string, body Event) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *said) say(c asks.Condition) error {
|
||||
func (s *said) say(c oc.Condition) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.conds = append(s.conds, c.State+" "+c.Key)
|
||||
@@ -462,9 +463,9 @@ func TestTheKeeperMakesWhatAVerdictAsksForAndSaysIt(t *testing.T) {
|
||||
k.Heard(Accepted{Module: "m", Revision: 2, BusState: []catalogue.BusStateEntry{log}})
|
||||
var acts []string
|
||||
k.Act = func(what, why, cause string) error { acts = append(acts, cause+": "+what); return nil }
|
||||
ask := asks.Ask{ID: "bus-1", Who: asks.Operator, Expires: k.now().Add(time.Hour)}
|
||||
var w *asks.Warrant
|
||||
k.Warrant = func(string) (*asks.Ask, *asks.Warrant, error) { return &ask, w, nil }
|
||||
ask := oc.Ask{ID: "bus-1", Who: occ.Operator, Expires: occ.FormatTime(k.now().Add(time.Hour))}
|
||||
var w *oc.Warrant
|
||||
k.Warrant = func(string) (*oc.Ask, *oc.Warrant, error) { return &ask, w, nil }
|
||||
put := func(r Request) Request {
|
||||
raw, _ := json.Marshal(r)
|
||||
_, _ = store.Put(StateRequests, r.ID, raw)
|
||||
@@ -480,13 +481,13 @@ func TestTheKeeperMakesWhatAVerdictAsksForAndSaysIt(t *testing.T) {
|
||||
if got := put(Request{ID: "r1", Verb: VerbDrop, Module: "m", Local: "drafts", Ask: "bus-1", Caller: "person.jo"}); !got.Done.IsZero() || len(bus.deleted) != 0 {
|
||||
t.Fatalf("acted with no warrant: %+v, deleted %v", got, bus.deleted)
|
||||
}
|
||||
binds := func(act asks.Act) string { d, _ := asks.ActDigest(act); return d }
|
||||
choose := func(act asks.Act) {
|
||||
ask.Options = []asks.Option{{ID: "go", Label: "Remove", Level: asks.Destroy, Binds: binds(act)}}
|
||||
w = &asks.Warrant{Ask: "bus-1", Asker: Module, Outcome: asks.OutcomeChosen, By: &asks.Person{Who: asks.Operator},
|
||||
Option: "go", Label: "Remove", Level: asks.Destroy, At: k.now(), AskDigest: ask.Digest()}
|
||||
binds := func(act occ.Act) string { d, _ := occ.ActDigest(act); return d }
|
||||
choose := func(act occ.Act) {
|
||||
ask.Options = []oc.Option{{ID: "go", Label: "Remove", Level: occ.Destroy, Binds: binds(act)}}
|
||||
w = &oc.Warrant{Ask: "bus-1", Asker: Module, Outcome: occ.OutcomeChosen, By: &oc.Person{Who: occ.Operator},
|
||||
Option: "go", Label: "Remove", Level: occ.Destroy, At: occ.FormatTime(k.now()), AskDigest: occ.Digest(ask)}
|
||||
}
|
||||
choose(asks.Act{"verb": "drop", "module": "m", "local": "log"})
|
||||
choose(occ.Act{"verb": "drop", "module": "m", "local": "log"})
|
||||
if got := put(Request{ID: "r1", Verb: VerbDrop, Module: "m", Local: "drafts", Ask: "bus-1", Caller: "person.jo"}); !strings.Contains(got.Error, "bound") || len(bus.deleted) != 0 {
|
||||
t.Fatalf("acted on a warrant for another act: %+v, deleted %v", got, bus.deleted)
|
||||
}
|
||||
@@ -498,15 +499,15 @@ func TestTheKeeperMakesWhatAVerdictAsksForAndSaysIt(t *testing.T) {
|
||||
}
|
||||
}
|
||||
refused(Request{ID: "r1a", Verb: VerbDrop, Module: "m", Local: "drafts", Caller: "person.jo"}, "names no ask")
|
||||
choose(asks.Act{"verb": "drop", "module": "m", "local": "drafts"})
|
||||
w.Level, w.Label, ask.Options[0].Level = asks.Approve, "Keep", asks.Approve
|
||||
w.AskDigest = ask.Digest()
|
||||
choose(occ.Act{"verb": "drop", "module": "m", "local": "drafts"})
|
||||
w.Level, w.Label, ask.Options[0].Level = occ.Approve, "Keep", occ.Approve
|
||||
w.AskDigest = occ.Digest(ask)
|
||||
refused(Request{ID: "r1b", Verb: VerbDrop, Module: "m", Local: "drafts", Ask: "bus-1", Caller: "person.jo"}, "nothing is removed")
|
||||
choose(asks.Act{"verb": "drop", "module": "m", "local": "drafts"})
|
||||
choose(occ.Act{"verb": "drop", "module": "m", "local": "drafts"})
|
||||
w.Asker = "mesh-grants"
|
||||
refused(Request{ID: "r1c", Verb: VerbDrop, Module: "m", Local: "drafts", Ask: "bus-1", Caller: "person.jo"}, "no warrant")
|
||||
refused(Request{ID: "r1d", Verb: VerbCreate, Module: "m", Local: "drafts", Caller: "person.jo"}, "no accepted manifest of m asks for drafts")
|
||||
choose(asks.Act{"verb": "drop", "module": "m", "local": "drafts"})
|
||||
choose(occ.Act{"verb": "drop", "module": "m", "local": "drafts"})
|
||||
bus.refuse["snapshot"] = errors.New("no space")
|
||||
refused(Request{ID: "r1e", Verb: VerbDrop, Module: "m", Local: "drafts", Ask: "bus-1", Caller: "person.jo"}, "the snapshot before drop failed, so nothing was removed")
|
||||
delete(bus.refuse, "snapshot")
|
||||
@@ -526,11 +527,11 @@ func TestTheKeeperMakesWhatAVerdictAsksForAndSaysIt(t *testing.T) {
|
||||
store := k.Store.(*memStore)
|
||||
k.Heard(Accepted{Module: "m", Revision: 1, BusState: []catalogue.BusStateEntry{log}})
|
||||
k.Heard(Accepted{Module: "m", Revision: 2, BusState: []catalogue.BusStateEntry{entry("log", catalogue.StorageStream, catalogue.TemplateLog, map[string]string{"max-mib": "16"})}})
|
||||
binds, _ := asks.ActDigest(asks.Act{"verb": "recreate", "module": "m", "local": "log"})
|
||||
ask := asks.Ask{ID: "bus-2", Who: asks.Operator, Expires: k.now().Add(time.Hour), Options: []asks.Option{{ID: "go", Label: "Rebuild", Level: asks.Destroy, Binds: binds}}}
|
||||
w := &asks.Warrant{Ask: "bus-2", Asker: Module, Outcome: asks.OutcomeChosen, By: &asks.Person{Who: asks.Operator},
|
||||
Option: "go", Label: "Rebuild", Level: asks.Destroy, At: k.now(), AskDigest: ask.Digest()}
|
||||
k.Warrant = func(string) (*asks.Ask, *asks.Warrant, error) { return &ask, w, nil }
|
||||
binds, _ := occ.ActDigest(occ.Act{"verb": "recreate", "module": "m", "local": "log"})
|
||||
ask := oc.Ask{ID: "bus-2", Who: occ.Operator, Expires: occ.FormatTime(k.now().Add(time.Hour)), Options: []oc.Option{{ID: "go", Label: "Rebuild", Level: occ.Destroy, Binds: binds}}}
|
||||
w := &oc.Warrant{Ask: "bus-2", Asker: Module, Outcome: occ.OutcomeChosen, By: &oc.Person{Who: occ.Operator},
|
||||
Option: "go", Label: "Rebuild", Level: occ.Destroy, At: occ.FormatTime(k.now()), AskDigest: occ.Digest(ask)}
|
||||
k.Warrant = func(string) (*oc.Ask, *oc.Warrant, error) { return &ask, w, nil }
|
||||
out.reset()
|
||||
raw, _ := json.Marshal(Request{ID: "r3", Verb: VerbRecreate, Module: "m", Local: "log", Ask: "bus-2"})
|
||||
_, _ = store.Put(StateRequests, "r3", raw)
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
|
||||
)
|
||||
|
||||
// A person's request (design 25 § 12.3; CAT-N8): the bus module's tools hold no grant of the seat's acting verbs, so
|
||||
@@ -46,9 +46,9 @@ type Request struct {
|
||||
Snapshot string `json:"snapshot,omitempty"`
|
||||
}
|
||||
|
||||
// Act is what an option of the operator's ask binds for a recreate or a drop: the verb and the entry (asks.ActDigest).
|
||||
func (r Request) Act() asks.Act {
|
||||
return asks.Act{"verb": r.Verb, "module": r.Module, "local": r.Local}
|
||||
// Act is what an option of the operator's ask binds for a recreate or a drop: the verb and the entry (occ.ActDigest).
|
||||
func (r Request) Act() occ.Act {
|
||||
return occ.Act{"verb": r.Verb, "module": r.Module, "local": r.Local}
|
||||
}
|
||||
|
||||
// ErrNoWarrant is a request that waits for the operator's word: kept, acted on once the warrant is held.
|
||||
@@ -197,14 +197,14 @@ func (k *Keeper) warranted(r *Request) error {
|
||||
if ask == nil || w == nil {
|
||||
return ErrNoWarrant
|
||||
}
|
||||
o, err := w.For(Module, *ask)
|
||||
o, err := occ.For(*w, Module, *ask)
|
||||
if err != nil {
|
||||
return fmt.Errorf("no warrant: %w", err)
|
||||
}
|
||||
if o.Level != asks.Destroy {
|
||||
if o.Level != occ.Destroy {
|
||||
return fmt.Errorf("the operator chose %s, so nothing is removed", o.Label)
|
||||
}
|
||||
return o.Performs(r.Act())
|
||||
return occ.Performs(o, r.Act())
|
||||
}
|
||||
|
||||
func (k *Keeper) record(r *Request, what string) {
|
||||
|
||||
@@ -17,8 +17,9 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
|
||||
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
)
|
||||
|
||||
// Module is this module's name: the asker of its asks on the operator's channel.
|
||||
@@ -51,29 +52,29 @@ func RoundOpening(at time.Time) time.Time {
|
||||
|
||||
// PassOperatorAsk is the ask on the operator's channel for a pass: composed from the pass alone, so the tool that
|
||||
// publishes it and the keeper that reads its answer compose the same ask, with the same digest.
|
||||
func PassOperatorAsk(round, node, next, reason, id string, at time.Time) asks.Ask {
|
||||
func PassOperatorAsk(round, node, next, reason, id string, at time.Time) oc.Ask {
|
||||
headline := "Upgrade round: pass " + node
|
||||
if len([]rune(headline)) > asks.HeadlineLength {
|
||||
if len([]rune(headline)) > occ.HeadlineLength {
|
||||
headline = "Upgrade round: pass a node"
|
||||
}
|
||||
binds, _ := asks.ActDigest(passAct(round, node, reason, id))
|
||||
return asks.Ask{
|
||||
binds, _ := occ.ActDigest(passAct(round, node, reason, id))
|
||||
return oc.Ask{
|
||||
ID: id,
|
||||
Headline: headline,
|
||||
Explanation: fmt.Sprintf("The week's upgrade round %s stands at %s. Passing it lets %s and the nodes after it upgrade; "+
|
||||
"%s is not upgraded in this round.\nThe reason given: %s", round, node, next, node, reason),
|
||||
Options: []asks.Option{
|
||||
{ID: "pass", Label: "Pass it", Does: "the round goes on past " + node, Level: asks.Approve, Binds: binds},
|
||||
{ID: "keep", Label: "Keep it waiting", Does: "the round stays at " + node, Level: asks.Acknowledge},
|
||||
Options: []oc.Option{
|
||||
{ID: "pass", Label: "Pass it", Does: "the round goes on past " + node, Level: occ.Approve, Binds: binds},
|
||||
{ID: "keep", Label: "Keep it waiting", Does: "the round stays at " + node, Level: occ.Acknowledge},
|
||||
},
|
||||
Who: asks.Operator,
|
||||
Expires: at.UTC().Add(passLasts),
|
||||
Who: occ.Operator,
|
||||
Expires: occ.FormatTime(at.UTC().Add(passLasts)),
|
||||
OnExpiry: "the round stays at " + node,
|
||||
}
|
||||
}
|
||||
|
||||
func passAct(round, node, reason, id string) asks.Act {
|
||||
return asks.Act{"verb": Module + ".pass", "round": round, "node": node, "reason": reason, "ask": id}
|
||||
func passAct(round, node, reason, id string) occ.Act {
|
||||
return occ.Act{"verb": Module + ".pass", "round": round, "node": node, "reason": reason, "ask": id}
|
||||
}
|
||||
|
||||
// roundView is one round as the bus holds it: the round, each node's place, and each node's run in it.
|
||||
@@ -343,8 +344,8 @@ func (u *Upgrades) readPass(v *roundView, node string) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
var rec struct {
|
||||
State string `json:"state"`
|
||||
Warrant *asks.Warrant `json:"warrant"`
|
||||
State string `json:"state"`
|
||||
Warrant *oc.Warrant `json:"warrant"`
|
||||
}
|
||||
if err := json.Unmarshal(e.Value, &rec); err != nil {
|
||||
return false, fmt.Errorf("the channel's record of the pass of %s: %w", node, err)
|
||||
@@ -355,13 +356,13 @@ func (u *Upgrades) readPass(v *roundView, node string) (bool, error) {
|
||||
w := *rec.Warrant
|
||||
answer := PassDeclined
|
||||
switch {
|
||||
case w.Outcome == asks.OutcomeExpired:
|
||||
case w.Outcome == occ.OutcomeExpired:
|
||||
answer = PassExpired
|
||||
case w.Outcome == asks.OutcomeChosen:
|
||||
case w.Outcome == occ.OutcomeChosen:
|
||||
ask := PassOperatorAsk(v.Round.Round, node, v.next(node), p.Reason, p.Ask, p.Asked)
|
||||
option, err := w.For(Module, ask)
|
||||
option, err := occ.For(w, Module, ask)
|
||||
if err == nil {
|
||||
err = option.Performs(passAct(v.Round.Round, node, p.Reason, p.Ask))
|
||||
err = occ.Performs(option, passAct(v.Round.Round, node, p.Reason, p.Ask))
|
||||
}
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("the answer to the pass of %s is not the operator's to this pass: %w", node, err)
|
||||
@@ -547,10 +548,10 @@ func AskPass(rounds, runs, asksState Bucket, round, node, reason string, now tim
|
||||
}
|
||||
a := Ask{ID: AskID(now), Node: next, Act: ActPass, At: now.UTC(), Round: round, Pass: node, Reason: reason}
|
||||
opAsk := PassOperatorAsk(round, node, next, reason, a.ID, a.At)
|
||||
if err := opAsk.Check(now); err != nil {
|
||||
if err := occ.Check(opAsk, now); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := seatPublish(asks.AskSubject(Module), opAsk, opAsk.ID); err != nil {
|
||||
if err := seatPublish(oc.AcceptSubjectAsk(Module), opAsk, opAsk.ID); err != nil {
|
||||
return nil, fmt.Errorf("the operator could not be asked: %w; nothing was passed", err)
|
||||
}
|
||||
if _, err := asksState.Create(NodeKey(next, a.ID), a); err != nil {
|
||||
|
||||
@@ -9,8 +9,9 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
|
||||
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
)
|
||||
|
||||
// roundBed is three keepers over one bed's states, each with the order node-a, node-b, node-c.
|
||||
@@ -175,9 +176,9 @@ func TestANodeOffAtItsTurnRunsAtItsNextStartAndAStalledTurnIsSaid(t *testing.T)
|
||||
}
|
||||
|
||||
// warrant is the operator channel's record of an ask answered with option.
|
||||
func warrant(a asks.Ask, option string, level asks.Level, at time.Time) json.RawMessage {
|
||||
w := asks.Warrant{Ask: a.ID, Asker: Module, Outcome: asks.OutcomeChosen, Option: option, Level: level,
|
||||
By: &asks.Person{Who: asks.Operator, Kind: "made-up-channel", Identity: "1", Verified: "user id"}, At: at, AskDigest: a.Digest()}
|
||||
func warrant(a oc.Ask, option string, level string, at time.Time) json.RawMessage {
|
||||
w := oc.Warrant{Ask: a.ID, Asker: Module, Outcome: occ.OutcomeChosen, Option: option, Level: level,
|
||||
By: &oc.Person{Who: occ.Operator, Kind: "made-up-channel", Identity: "1", Verified: "user id"}, At: occ.FormatTime(at), AskDigest: occ.Digest(a)}
|
||||
raw, _ := json.Marshal(map[string]any{"state": "chosen", "warrant": w})
|
||||
return raw
|
||||
}
|
||||
@@ -190,11 +191,11 @@ func TestOnlyTheOperatorsApprovePassesANodeAndAPassNeedsAReason(t *testing.T) {
|
||||
b.step(t, "node-b")
|
||||
b.end(t, "node-b", "LoadState=loaded\nActiveState=failed\nResult=exit-code\nExecMainStatus=1\n")
|
||||
|
||||
var published []asks.Ask
|
||||
var published []oc.Ask
|
||||
var subjects []string
|
||||
saved := seatPublish
|
||||
seatPublish = func(subject string, body any, id string) error {
|
||||
subjects, published = append(subjects, subject), append(published, body.(asks.Ask))
|
||||
subjects, published = append(subjects, subject), append(published, body.(oc.Ask))
|
||||
return nil
|
||||
}
|
||||
defer func() { seatPublish = saved }()
|
||||
@@ -209,7 +210,7 @@ func TestOnlyTheOperatorsApprovePassesANodeAndAPassNeedsAReason(t *testing.T) {
|
||||
t.Fatalf("the operator is asked on their channel: %v %v", subjects, out)
|
||||
}
|
||||
opAsk := published[0]
|
||||
if err := opAsk.Check(b.now); err != nil || !strings.Contains(opAsk.Explanation, "its disk is being replaced") {
|
||||
if err := occ.Check(opAsk, b.now); err != nil || !strings.Contains(opAsk.Explanation, "its disk is being replaced") {
|
||||
t.Errorf("the ask: %v %+v", err, opAsk)
|
||||
}
|
||||
keys, _ := b.asks.Keys()
|
||||
@@ -239,11 +240,11 @@ func TestOnlyTheOperatorsApprovePassesANodeAndAPassNeedsAReason(t *testing.T) {
|
||||
// An answer to another ask, or to this one as it was not shown, passes nothing.
|
||||
other := opAsk
|
||||
other.Explanation = "something else"
|
||||
record[opAsk.ID] = warrant(other, "pass", asks.Approve, b.now)
|
||||
record[opAsk.ID] = warrant(other, "pass", occ.Approve, b.now)
|
||||
if _, err := b.nodes["node-c"].RoundStep(); err == nil || len(b.runsOf("node-c")) != 0 {
|
||||
t.Fatalf("a warrant for another ask: %v", err)
|
||||
}
|
||||
record[opAsk.ID] = warrant(opAsk, "pass", asks.Approve, b.now)
|
||||
record[opAsk.ID] = warrant(opAsk, "pass", occ.Approve, b.now)
|
||||
b.step(t, "node-c")
|
||||
if st := b.state(t)["node-b"]; st.State != InRoundPassed || st.Pass.Answer != PassApproved {
|
||||
t.Errorf("the operator's Approve passes node-b: %+v", st)
|
||||
@@ -258,8 +259,8 @@ func TestAPassTheOperatorKeepsWaitingPassesNothing(t *testing.T) {
|
||||
b.now = time.Date(2026, 10, 24, 4, 0, 0, 0, time.UTC)
|
||||
b.step(t, "node-a") // node-a runs and never ends
|
||||
saved := seatPublish
|
||||
var opAsk asks.Ask
|
||||
seatPublish = func(_ string, body any, _ string) error { opAsk = body.(asks.Ask); return nil }
|
||||
var opAsk oc.Ask
|
||||
seatPublish = func(_ string, body any, _ string) error { opAsk = body.(oc.Ask); return nil }
|
||||
defer func() { seatPublish = saved }()
|
||||
if _, err := AskPass(b.rounds, b.runs, b.asks, "2026-W43", "node-a", "it hangs", b.now); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -272,7 +273,7 @@ func TestAPassTheOperatorKeepsWaitingPassesNothing(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b.nodes["node-b"].AskRecord = func(string) (*stdio.StateEntry, error) {
|
||||
return &stdio.StateEntry{Value: warrant(opAsk, "keep", asks.Acknowledge, b.now)}, nil
|
||||
return &stdio.StateEntry{Value: warrant(opAsk, "keep", occ.Acknowledge, b.now)}, nil
|
||||
}
|
||||
b.step(t, "node-b")
|
||||
if st := b.state(t); st["node-a"].Pass.Answer != PassDeclined || len(b.runsOf("node-b")) != 0 {
|
||||
|
||||
@@ -3,6 +3,8 @@ module pacman
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-contracts/go/seats/operatorchannel v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128
|
||||
github.com/nats-io/nats-server/v2 v2.11.17
|
||||
)
|
||||
|
||||
@@ -39,7 +39,7 @@ import (
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
"git.novox.be/novox/mesh-sdk/go/channel"
|
||||
)
|
||||
|
||||
// Kind is this channel's kind on both benches.
|
||||
@@ -134,7 +134,7 @@ type Channel struct {
|
||||
meAt time.Time
|
||||
me string
|
||||
meErr string
|
||||
said asks.Standing
|
||||
said channel.Standing
|
||||
saidAt time.Time
|
||||
lastSent time.Time
|
||||
lastErr string
|
||||
@@ -167,7 +167,7 @@ func (c *Channel) init() {
|
||||
|
||||
// buttons are a message's buttons as the API takes them: a label and the ticket, which must fit the 64
|
||||
// bytes Telegram allows; one that does not is left out and said.
|
||||
func (c *Channel) buttons(m asks.Message) [][2]string {
|
||||
func (c *Channel) buttons(m channel.Message) [][2]string {
|
||||
var out [][2]string
|
||||
for _, b := range m.Buttons {
|
||||
if len(b.Ticket) == 0 || len(b.Ticket) > 64 {
|
||||
@@ -181,7 +181,7 @@ func (c *Channel) buttons(m asks.Message) [][2]string {
|
||||
|
||||
// chatFor is where a message goes: the linked account the router names, else the chat of the arrival it
|
||||
// replies to. Neither is a permanent failure: nobody is linked, and nobody asked.
|
||||
func (c *Channel) chatFor(m asks.Message) (int64, error) {
|
||||
func (c *Channel) chatFor(m channel.Message) (int64, error) {
|
||||
if m.To != "" {
|
||||
id, err := strconv.ParseInt(m.To, 10, 64)
|
||||
if err != nil {
|
||||
@@ -208,14 +208,14 @@ func (c *Channel) Take(subject string, body json.RawMessage) error {
|
||||
return nil
|
||||
}
|
||||
verb := parts[4]
|
||||
var m asks.Message
|
||||
var m channel.Message
|
||||
if err := json.Unmarshal(body, &m); err != nil || m.Handle == "" {
|
||||
c.Logf("[telegram] work on %s could not be read; taken and dropped", subject)
|
||||
return nil
|
||||
}
|
||||
// The router's word on a tap, said where the tap was made: the tap's own acknowledgement (issue 372). A
|
||||
// word of one line is that alone; one with more to it is also said in the chat, where it stays.
|
||||
if verb == asks.Send && m.Reply != "" && !m.Secret && c.settleTap(m.Reply, toast(m)) && strings.TrimSpace(m.Body) == "" {
|
||||
if verb == channel.Send && m.Reply != "" && !m.Secret && c.settleTap(m.Reply, toast(m)) && strings.TrimSpace(m.Body) == "" {
|
||||
c.mu.Lock()
|
||||
c.lastSent, c.lastErr = c.Now(), ""
|
||||
c.delivered++
|
||||
@@ -235,7 +235,7 @@ func (c *Channel) Take(subject string, body json.RawMessage) error {
|
||||
// delete loses its buttons and says a newer message follows. A send that fails leaves the earlier one.
|
||||
var place, earlier *Placed
|
||||
switch {
|
||||
case verb == asks.Edit:
|
||||
case verb == channel.Edit:
|
||||
if place, err = c.Store.Placed(m.Handle); err != nil {
|
||||
return err // the state is away: offered again
|
||||
}
|
||||
@@ -320,8 +320,8 @@ func (c *Channel) fail(handle string, err error, forGood bool) {
|
||||
c.failed++
|
||||
c.mu.Unlock()
|
||||
c.Logf("[telegram] a message was not delivered: %v", err)
|
||||
f := asks.Failed{Handle: handle, Why: err.Error(), Permanent: forGood, At: c.Now()}
|
||||
if perr := c.Publish(asks.IntakeSubject(asks.FailedWhat, Kind), f, "failed."+handle+"."+strconv.FormatInt(c.Now().UnixNano(), 10)); perr != nil {
|
||||
f := channel.Failed{Handle: handle, Why: err.Error(), Permanent: forGood, At: c.Now()}
|
||||
if perr := c.Publish(channel.IntakeSubject(channel.FailedWhat, Kind), f, "failed."+handle+"."+strconv.FormatInt(c.Now().UnixNano(), 10)); perr != nil {
|
||||
c.Logf("[telegram] and the router could not be told: %v", perr)
|
||||
}
|
||||
}
|
||||
@@ -332,8 +332,8 @@ func private(from *User, chat Chat) bool {
|
||||
return from != nil && !from.IsBot && chat.Type == "private" && chat.ID == from.ID
|
||||
}
|
||||
|
||||
func senderOf(u User) asks.Sender {
|
||||
return asks.Sender{Identity: strconv.FormatInt(u.ID, 10), Display: u.Display(), Authenticated: true}
|
||||
func senderOf(u User) channel.Sender {
|
||||
return channel.Sender{Identity: strconv.FormatInt(u.ID, 10), Display: u.Display(), Authenticated: true}
|
||||
}
|
||||
|
||||
// Handle takes one update.
|
||||
@@ -368,8 +368,8 @@ func (c *Channel) Handle(u Update) {
|
||||
c.mu.Lock()
|
||||
c.taps[id] = cb.ID
|
||||
c.mu.Unlock()
|
||||
ch := asks.Choice{ID: id, Ticket: cb.Data, Sender: senderOf(cb.From), At: now}
|
||||
if err := c.Publish(asks.IntakeSubject(asks.Chosen, Kind), ch, id); err != nil {
|
||||
ch := channel.Choice{ID: id, Ticket: cb.Data, Sender: senderOf(cb.From), At: now}
|
||||
if err := c.Publish(channel.IntakeSubject(channel.Chosen, Kind), ch, id); err != nil {
|
||||
c.Logf("[telegram] a tap could not be said to the router: %v", err)
|
||||
words := "The mesh could not take this answer now. Nothing was done. Try again in a minute."
|
||||
c.settleTap(id, words)
|
||||
@@ -386,8 +386,8 @@ func (c *Channel) Handle(u Update) {
|
||||
c.remember(id, m.Chat.ID)
|
||||
switch t := strings.TrimSpace(m.Text); {
|
||||
case t == "/start" || strings.HasPrefix(t, "/start "):
|
||||
l := asks.LinkAsked{ID: id, Sender: senderOf(*m.From), At: now}
|
||||
if err := c.Publish(asks.IntakeSubject(asks.Link, Kind), l, id); err != nil {
|
||||
l := channel.LinkAsked{ID: id, Sender: senderOf(*m.From), At: now}
|
||||
if err := c.Publish(channel.IntakeSubject(channel.Link, Kind), l, id); err != nil {
|
||||
c.reply(m.Chat.ID, "The mesh could not take this now. Try again in a minute.")
|
||||
}
|
||||
case codeShape.MatchString(t):
|
||||
@@ -396,12 +396,12 @@ func (c *Channel) Handle(u Update) {
|
||||
if err := c.Bot.Delete(m.Chat.ID, m.MessageID); err != nil {
|
||||
c.Logf("[telegram] a code could not be deleted from the chat: %v", err)
|
||||
}
|
||||
raw, err := c.Prove(asks.ProofSubject(asks.CodeProof, Kind), asks.Code{Sender: senderOf(*m.From), Code: code, Purpose: "link"})
|
||||
raw, err := c.Prove(channel.ProofSubject(channel.CodeProof, Kind), channel.Code{Sender: senderOf(*m.From), Code: code, Purpose: "link"})
|
||||
if err != nil {
|
||||
c.reply(m.Chat.ID, "The mesh could not check the code now. Try again in a minute.")
|
||||
return
|
||||
}
|
||||
var a asks.ProofAnswer
|
||||
var a channel.ProofAnswer
|
||||
if json.Unmarshal(raw, &a) != nil || a.Words == "" {
|
||||
a.Words = "The mesh's answer could not be read."
|
||||
}
|
||||
@@ -432,7 +432,7 @@ func (c *Channel) settleTap(arrival, words string) bool {
|
||||
|
||||
// toast is a message as a tap's acknowledgement shows it: its title and the first line of its body, cut at
|
||||
// ToastLength.
|
||||
func toast(m asks.Message) string {
|
||||
func toast(m channel.Message) string {
|
||||
t := strings.TrimSpace(m.Title)
|
||||
if first, _, _ := strings.Cut(strings.TrimSpace(m.Body), "\n"); first != "" {
|
||||
t += ": " + first
|
||||
@@ -502,10 +502,10 @@ func (c *Channel) Poll(wait time.Duration) error {
|
||||
}
|
||||
|
||||
// Standing is whether the channel can send now: the token works and the updates arrive.
|
||||
func (c *Channel) Standing() asks.Standing {
|
||||
func (c *Channel) Standing() channel.Standing {
|
||||
c.init()
|
||||
now := c.Now()
|
||||
s := asks.Standing{EditsSilently: true, At: now}
|
||||
s := channel.Standing{EditsSilently: true, At: now}
|
||||
if _, err := c.Bot.token(); err != nil {
|
||||
s.Why = err.Error()
|
||||
return s
|
||||
@@ -545,7 +545,7 @@ func (c *Channel) SayStanding() {
|
||||
if same {
|
||||
return
|
||||
}
|
||||
if err := c.Publish(asks.IntakeSubject(asks.StandingWhat, Kind), s, "standing."+strconv.FormatInt(s.At.UnixNano(), 10)); err != nil {
|
||||
if err := c.Publish(channel.IntakeSubject(channel.StandingWhat, Kind), s, "standing."+strconv.FormatInt(s.At.UnixNano(), 10)); err != nil {
|
||||
c.Logf("[telegram] could not say whether it can send: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user