Add drill: an ask the operator starts at the controller's terminal, whose approval performs nothing and is recorded (hq ADR 0259)
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,
|
// 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.
|
// then "done", "failed: …" or "nothing: …". Anything but empty is never acted on again.
|
||||||
Acted string `json:"acted,omitempty"`
|
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).
|
// askedStore keeps the asks (broker.AskedBucket).
|
||||||
@@ -202,7 +205,7 @@ func (a *asker) reconcile(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
byCondition := map[string]asked{}
|
byCondition := map[string]asked{}
|
||||||
for _, r := range all {
|
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) {
|
if prior, held := byCondition[r.Condition]; !held || r.Opened.After(prior.Opened) {
|
||||||
byCondition[r.Condition] = r
|
byCondition[r.Condition] = r
|
||||||
}
|
}
|
||||||
@@ -226,7 +229,7 @@ func (a *asker) reconcile(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
byCondition = map[string]asked{}
|
byCondition = map[string]asked{}
|
||||||
for _, r := range all {
|
for _, r := range all {
|
||||||
if r.State == askOpen {
|
if r.State == askOpen && !r.Drill {
|
||||||
byCondition[r.Condition] = r
|
byCondition[r.Condition] = r
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -490,7 +493,7 @@ func (a *asker) Decided(ctx context.Context, body []byte) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
stillOpen := false
|
stillOpen := r.Drill // a drill is about no condition
|
||||||
for _, c := range open {
|
for _, c := range open {
|
||||||
stillOpen = stillOpen || c.Key == r.Condition
|
stillOpen = stillOpen || c.Key == r.Condition
|
||||||
}
|
}
|
||||||
@@ -521,9 +524,12 @@ func (a *asker) Decided(ctx context.Context, body []byte) error {
|
|||||||
args["why"] = why
|
args["why"] = why
|
||||||
}
|
}
|
||||||
var acted error
|
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)
|
acted = a.silence(ctx, act.Arguments["silence"], conditions.MaxSilence, byWords(w), why)
|
||||||
} else {
|
default:
|
||||||
acted = a.call(ctx, act, args)
|
acted = a.call(ctx, act, args)
|
||||||
}
|
}
|
||||||
r.Ended = a.now()
|
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:])
|
return rotateCommand(ctx, args[1:])
|
||||||
case "ask":
|
case "ask":
|
||||||
return askCommand(ctx, args[1:])
|
return askCommand(ctx, args[1:])
|
||||||
|
case "drill":
|
||||||
|
return drillCommand(ctx, args[1:])
|
||||||
case "builds":
|
case "builds":
|
||||||
return buildsCommand(ctx, args[1:])
|
return buildsCommand(ctx, args[1:])
|
||||||
// The build queue, controlled by hand (novox/hq ADR 0219).
|
// The build queue, controlled by hand (novox/hq ADR 0219).
|
||||||
|
|||||||
Reference in New Issue
Block a user