Add drill: an ask the operator starts at the controller's terminal, whose approval performs nothing and is recorded (hq ADR 0259)
mesh/merge-gate pass: builds build-agent, mesh-controller, route-proxy → ace, g14, novox, shanks; no bus step; every machine composes with the change as it…
mesh/repo-check fail: its merge-check.sh failed: --- FAIL: TestTheInstallersFirstUserListIsWhatTheControllerWouldCompose (0.65s)
mesh/delivery-group group feat/the-controller-asks-the-operator rejected: a member's own check failed
mesh/delivery superseded: a newer head of the same pull request
mesh/merge-gate pass: builds build-agent, mesh-controller, route-proxy → ace, g14, novox, shanks; no bus step; every machine composes with the change as it…
mesh/repo-check fail: its merge-check.sh failed: --- FAIL: TestTheInstallersFirstUserListIsWhatTheControllerWouldCompose (0.65s)
mesh/delivery-group group feat/the-controller-asks-the-operator rejected: a member's own check failed
mesh/delivery superseded: a newer head of the same pull request
The live acceptance needs an approval the operator can ask for at will and that changes nothing. A drill is asked like any condition's ask, bound to its own act, claimed once on its warrant and recorded as a warrant hand-act with who answered, through which channel and the proofs. A verb's process may not start one, so no agent asks the operator a question they did not start.
This commit is contained in:
@@ -84,6 +84,9 @@ type asked struct {
|
||||
// Acted is what the controller did on the warrant: empty before it did anything, "acting" while it acts,
|
||||
// then "done", "failed: …" or "nothing: …". Anything but empty is never acted on again.
|
||||
Acted string `json:"acted,omitempty"`
|
||||
// Drill is an ask started at the controller's terminal (drill.go): about no condition, its answers
|
||||
// perform nothing, and the reconciling of conditions leaves it alone.
|
||||
Drill bool `json:"drill,omitempty"`
|
||||
}
|
||||
|
||||
// askedStore keeps the asks (broker.AskedBucket).
|
||||
@@ -202,7 +205,7 @@ func (a *asker) reconcile(ctx context.Context) error {
|
||||
}
|
||||
byCondition := map[string]asked{}
|
||||
for _, r := range all {
|
||||
if r.State == askOpen {
|
||||
if r.State == askOpen && !r.Drill {
|
||||
if prior, held := byCondition[r.Condition]; !held || r.Opened.After(prior.Opened) {
|
||||
byCondition[r.Condition] = r
|
||||
}
|
||||
@@ -226,7 +229,7 @@ func (a *asker) reconcile(ctx context.Context) error {
|
||||
}
|
||||
byCondition = map[string]asked{}
|
||||
for _, r := range all {
|
||||
if r.State == askOpen {
|
||||
if r.State == askOpen && !r.Drill {
|
||||
byCondition[r.Condition] = r
|
||||
}
|
||||
}
|
||||
@@ -490,7 +493,7 @@ func (a *asker) Decided(ctx context.Context, body []byte) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stillOpen := false
|
||||
stillOpen := r.Drill // a drill is about no condition
|
||||
for _, c := range open {
|
||||
stillOpen = stillOpen || c.Key == r.Condition
|
||||
}
|
||||
@@ -521,9 +524,12 @@ func (a *asker) Decided(ctx context.Context, body []byte) error {
|
||||
args["why"] = why
|
||||
}
|
||||
var acted error
|
||||
if act.Arguments["silence"] != "" {
|
||||
switch {
|
||||
case r.Drill && act.Verb == drillVerb:
|
||||
// A drill's answer performs nothing: it is recorded below as the operator's decision.
|
||||
case act.Arguments["silence"] != "":
|
||||
acted = a.silence(ctx, act.Arguments["silence"], conditions.MaxSilence, byWords(w), why)
|
||||
} else {
|
||||
default:
|
||||
acted = a.call(ctx, act, args)
|
||||
}
|
||||
r.Ended = a.now()
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
package main
|
||||
|
||||
// The drill of the operator's answers (novox/hq ADR 0259, the live acceptance after rollout): an ask the
|
||||
// operator starts at the controller's terminal, answered on the phone, whose approval changes nothing and is
|
||||
// recorded as a person's decision like any other.
|
||||
//
|
||||
// mesh-controller drill [--for 15m]
|
||||
//
|
||||
// It asks with two answers — Approve (an approval, so only a channel that proves who answered carries it) and
|
||||
// Decline (an acknowledgement) — each bound to the drill's own act. The serving controller acts on the warrant
|
||||
// as on any other: it claims the ask once, checks the act is the one bound, performs nothing, and records the
|
||||
// hand-act `warrant` with who answered, through which channel, and the proofs. `hand-acts` then shows it.
|
||||
//
|
||||
// **The terminal's alone**: a command a verb runs (MESH_VERB set) is refused, so no agent starts a drill — a
|
||||
// drill is a question the operator expects, and one an agent could start would teach them to approve what they
|
||||
// did not ask for.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/conditions"
|
||||
)
|
||||
|
||||
// drillVerb is the act a drill's answers bind: nothing is called.
|
||||
const drillVerb = "drill"
|
||||
|
||||
// drillActions are the drill's two answers.
|
||||
func drillActions() []conditions.Action {
|
||||
return []conditions.Action{
|
||||
{Label: "Approve", Verb: drillVerb, Level: conditions.LevelApprove, Arguments: map[string]string{"drill": "approve"}},
|
||||
{Label: "Decline", Verb: drillVerb, Level: conditions.LevelAcknowledge, Arguments: map[string]string{"drill": "decline"}},
|
||||
}
|
||||
}
|
||||
|
||||
// drillAsk is the drill's ask, as the router is sent it.
|
||||
func drillAsk(id string, now time.Time, lasts time.Duration) (asks.Ask, map[string]int) {
|
||||
q := asks.Ask{ID: id, Headline: "Drill: approve this test question?", Who: asks.Operator,
|
||||
Explanation: "Needs you: approve or decline. You started this drill at the controller's terminal. Approving " +
|
||||
"changes nothing on the mesh; it is recorded as your decision, so you can check the record.",
|
||||
OnExpiry: "nothing is done", Expires: now.Add(lasts), About: "drill." + id}
|
||||
options := map[string]int{}
|
||||
for i, act := range drillActions() {
|
||||
binds, _ := asks.ActDigest(boundAct(act))
|
||||
oid := optionID(act.Label)
|
||||
options[oid] = i
|
||||
q.Options = append(q.Options, asks.Option{ID: oid, Label: act.Label, Does: doesDrill(act), Level: asks.Level(act.Level),
|
||||
Binds: binds})
|
||||
}
|
||||
return q, options
|
||||
}
|
||||
|
||||
func doesDrill(act conditions.Action) string {
|
||||
if act.Arguments["drill"] == "approve" {
|
||||
return "nothing changes; your approval is recorded"
|
||||
}
|
||||
return "nothing changes; your answer is recorded"
|
||||
}
|
||||
|
||||
func drillCommand(ctx context.Context, args []string) error {
|
||||
if os.Getenv(verbVar) != "" {
|
||||
return errors.New("drill is the controller's terminal's alone: a verb may not start one, so no agent asks " +
|
||||
"the operator a question they did not start (novox/hq ADR 0259)")
|
||||
}
|
||||
set := flag.NewFlagSet("drill", flag.ContinueOnError)
|
||||
lasts := set.Duration("for", 15*time.Minute, "how long the question waits for an answer")
|
||||
if err := set.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
if *lasts < time.Minute || *lasts > askApproveFor {
|
||||
return fmt.Errorf("a drill waits between a minute and %s", askApproveFor)
|
||||
}
|
||||
js, err := aBus()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer js.Close()
|
||||
now := time.Now()
|
||||
id := newAskID()
|
||||
q, options := drillAsk(id, now, *lasts)
|
||||
if err := q.Check(now); err != nil {
|
||||
return err
|
||||
}
|
||||
store := busAsked{conn: js.Conn()}
|
||||
// Kept before it is published, as the asker keeps every ask, so a warrant always finds it.
|
||||
if err := store.Put(ctx, asked{ID: id, Condition: q.About, Ask: q, Actions: drillActions(), Options: options,
|
||||
State: askOpen, Opened: now, Drill: true}); err != nil {
|
||||
return fmt.Errorf("the drill could not be kept in the controller's asks: %w", err)
|
||||
}
|
||||
body, err := json.Marshal(q)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := js.Context().Publish(asks.AskSubject(askerName), body, nats.MsgId("ask."+id), nats.Context(ctx)); err != nil {
|
||||
return fmt.Errorf("the drill could not be asked: %w", err)
|
||||
}
|
||||
fmt.Printf("drill %s asked: answer it on your phone before %s. Then `mesh-controller hand-acts` shows the "+
|
||||
"answer as a warrant, with who answered, through which channel and the proofs; nothing else changes.\n",
|
||||
id, q.Expires.Local().Format("15:04"))
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
)
|
||||
|
||||
// A drill (the live acceptance of novox/hq ADR 0259): its approval is a warrant like any other — claimed once,
|
||||
// its act checked against what the option bound, recorded as the operator's decision with who, how and the
|
||||
// proofs — and it performs nothing. The reconciling of conditions leaves it open.
|
||||
func TestADrillsApprovalIsRecordedAndPerformsNothing(t *testing.T) {
|
||||
r := newAskerRig(t)
|
||||
q, options := drillAsk("cdrill", r.now, askerDrillFor)
|
||||
if err := q.Check(r.now); err != nil {
|
||||
t.Fatalf("the drill's ask is refused: %v", err)
|
||||
}
|
||||
r.store["cdrill"] = asked{ID: "cdrill", Condition: q.About, Ask: q, Actions: drillActions(), Options: options,
|
||||
State: askOpen, Opened: r.now, Drill: true}
|
||||
if err := r.a.reconcile(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := r.store["cdrill"]; got.State != askOpen {
|
||||
t.Fatalf("the reconciling of conditions ended the drill: %+v", got)
|
||||
}
|
||||
approve, _ := q.Option("approve")
|
||||
w := asks.Warrant{Ask: "cdrill", Asker: "mesh-controller", Outcome: asks.OutcomeChosen, Option: approve.ID,
|
||||
Label: approve.Label, Level: approve.Level, Channel: "telegram", Proofs: []string{"P1"}, At: r.now,
|
||||
AskDigest: q.Digest(), By: &asks.Person{Who: asks.Operator, Kind: "telegram", Identity: "42", Verified: "user id verified"}}
|
||||
answerWith(t, r, w)
|
||||
answerWith(t, r, w) // heard again
|
||||
if len(r.called)+len(r.silenced) != 0 {
|
||||
t.Errorf("a drill performed something: %v %v", r.called, r.silenced)
|
||||
}
|
||||
if len(r.acts) != 1 {
|
||||
t.Fatalf("hand-acts %+v", r.acts)
|
||||
}
|
||||
act := r.acts[0]
|
||||
if act.Verb != handActWarrant || act.By != "the operator, as telegram identity 42" || act.Ask != "cdrill" ||
|
||||
strings.Join(act.Args, " ") != "drill drill=approve" || act.Outcome != "done" || strings.Join(act.Proofs, ",") != "P1" {
|
||||
t.Errorf("the drill's record: %+v", act)
|
||||
}
|
||||
if !personsDecision(act) {
|
||||
t.Error("a drill's answer counts as a repair")
|
||||
}
|
||||
}
|
||||
|
||||
// Only the terminal starts a drill: a verb's process is refused before anything is asked.
|
||||
func TestADrillIsTheTerminalsAlone(t *testing.T) {
|
||||
t.Setenv(verbVar, "mesh-controller.command")
|
||||
if err := drillCommand(context.Background(), nil); err == nil || !strings.Contains(err.Error(), "terminal") {
|
||||
t.Fatalf("a verb started a drill: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// askerDrillFor is how long the test's drill waits.
|
||||
const askerDrillFor = 15 * time.Minute
|
||||
@@ -78,6 +78,8 @@ func run() error {
|
||||
return rotateCommand(ctx, args[1:])
|
||||
case "ask":
|
||||
return askCommand(ctx, args[1:])
|
||||
case "drill":
|
||||
return drillCommand(ctx, args[1:])
|
||||
case "builds":
|
||||
return buildsCommand(ctx, args[1:])
|
||||
// The build queue, controlled by hand (novox/hq ADR 0219).
|
||||
|
||||
Reference in New Issue
Block a user