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})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user