Tell the operator when the answer to a plan's ask is refused (issue 1316, ADR 0449)
This commit is contained in:
@@ -208,8 +208,11 @@ type asker struct {
|
||||
// raise keeps the asker's own condition (sourceAsker): which conditions needing the operator could not be
|
||||
// asked, and why. Nil raises nothing (a test that does not look).
|
||||
raise func(ctx context.Context, obs []conditions.Observation) error
|
||||
now func() time.Time
|
||||
logf func(string, ...any)
|
||||
// tell tells the operator one plain notice that needs no answer (operator-channel.tell, novox/hq ADR 0380):
|
||||
// the refusal of an answer to a plan's ask (ADR 0449 §3). Nil tells nothing (a test that does not look).
|
||||
tell func(ctx context.Context, headline string, lines []string) error
|
||||
now func() time.Time
|
||||
logf func(string, ...any)
|
||||
|
||||
saidNoRouter bool
|
||||
saidNoGrant bool
|
||||
@@ -886,6 +889,7 @@ func (a *asker) Decided(ctx context.Context, body []byte) error {
|
||||
ended := a.now()
|
||||
if acted != nil {
|
||||
outcome = "failed: " + acted.Error()
|
||||
a.tellRefused(ctx, *r, act, acted)
|
||||
}
|
||||
r.Ended, r.Acted = ended, outcome
|
||||
if _, err := a.store.Change(ctx, r.ID, func(x *asked) bool {
|
||||
@@ -920,6 +924,35 @@ func (a *asker) Decided(ctx context.Context, body []byte) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// tellRefused tells the operator that the answer they gave to a plan's ask was refused (novox/hq ADR 0449 §3): the
|
||||
// controller's `plans` verb refuses a Retry, Start or Stop when the plan moved on between the ask and the answer,
|
||||
// and the refusal was only recorded, so the operator who pressed the answer heard nothing. One plain notice with the
|
||||
// ask's headline and the refusal's words, beside the hand act the caller still records. An answer to any other ask
|
||||
// is not told here. A notice that cannot be told is said in the log and changes nothing.
|
||||
func (a *asker) tellRefused(ctx context.Context, r asked, act conditions.Action, refusal error) {
|
||||
if a.tell == nil || act.Verb != broker.ControllerSeat+".plans" {
|
||||
return
|
||||
}
|
||||
lines := []string{
|
||||
fmt.Sprintf("Your answer, %s, could not be done: %s.", act.Label, strings.TrimRight(refusalWords(refusal), ".")),
|
||||
"Nothing was changed.",
|
||||
}
|
||||
if err := a.tell(ctx, r.Ask.Headline, lines); err != nil {
|
||||
a.logf("the operator could NOT be told that %s was refused (ask %s): %v", act.Label, r.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// refusalWords is a refused verb's words as the operator reads them: without the verb's address in front, with the
|
||||
// plans named as "the plan" (plainRefusal), and, where they still carry an identifier the operator's channel
|
||||
// withholds, only that the controller refused it.
|
||||
func refusalWords(err error) string {
|
||||
text := err.Error()
|
||||
if i := strings.Index(text, " refused: "); i >= 0 {
|
||||
text = text[i+len(" refused: "):]
|
||||
}
|
||||
return plainRefusal(text)
|
||||
}
|
||||
|
||||
// handActWarrant is the verb an act the operator chose on a warrant is recorded under: a person's decision,
|
||||
// never a repair (handActVerbs).
|
||||
const handActWarrant = "warrant"
|
||||
|
||||
@@ -77,6 +77,15 @@ type askerRig struct {
|
||||
silenced []string
|
||||
acts []link.HandAct
|
||||
now time.Time
|
||||
// refuses, when set, is what every verb the asker calls answers; told is every notice the operator was told.
|
||||
refuses error
|
||||
told []told
|
||||
}
|
||||
|
||||
// told is one plain notice the asker told the operator.
|
||||
type told struct {
|
||||
headline string
|
||||
lines []string
|
||||
}
|
||||
|
||||
func newAskerRig(t *testing.T) *askerRig {
|
||||
@@ -102,6 +111,10 @@ func newAskerRig(t *testing.T) *askerRig {
|
||||
call: func(_ context.Context, a conditions.Action, args map[string]string) error {
|
||||
raw, _ := json.Marshal(args)
|
||||
r.called = append(r.called, a.Verb+"@"+a.Machine+" "+string(raw))
|
||||
return r.refuses
|
||||
},
|
||||
tell: func(_ context.Context, headline string, lines []string) error {
|
||||
r.told = append(r.told, told{headline, lines})
|
||||
return nil
|
||||
},
|
||||
record: func(_ context.Context, act link.HandAct) error { r.acts = append(r.acts, act); return nil },
|
||||
@@ -1074,3 +1087,76 @@ func TestAWarrantForRetryCallsPlansRetryOnceAndIsRecorded(t *testing.T) {
|
||||
t.Errorf("kept %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 1316/AC1 and AC2: a Retry or a Stop on a plan that moved on is refused by `plans`; the operator who pressed it is
|
||||
// told once, with the ask's headline and the refusal's words, and the hand act records the failure as before.
|
||||
func TestARefusedAnswerToAPlansAskIsToldOnceAndStillRecorded(t *testing.T) {
|
||||
plan := "plan-1792433168676870614"
|
||||
waiting := conditions.Condition{Key: "plan." + plan + ".waiting", Severity: conditions.Urgent,
|
||||
Headline: "openrazer delivery waiting to start", Needs: "start it, or stop it.",
|
||||
Explanation: "Needs you: start it, or stop it.", Actions: waitingActions(plan, conditions.Urgent)}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
c conditions.Condition
|
||||
label string
|
||||
}{
|
||||
{"retry", failedPlanCondition(plan), "Retry"},
|
||||
{"stop", waiting, "Stop"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
r := newAskerRig(t)
|
||||
r.refuses = errors.New("mesh-controller.plans refused: " + plan + " is done; there is nothing to retry")
|
||||
r.open = []conditions.Condition{tc.c}
|
||||
_ = r.a.reconcile(context.Background())
|
||||
w := r.warrantFor(t, tc.c.Key, tc.label)
|
||||
answerWith(t, r, w)
|
||||
answerWith(t, r, w) // heard again: told once
|
||||
if len(r.told) != 1 {
|
||||
t.Fatalf("told %+v", r.told)
|
||||
}
|
||||
got := r.told[0]
|
||||
if got.headline != tc.c.Headline {
|
||||
t.Errorf("the headline is %q, not the ask's %q", got.headline, tc.c.Headline)
|
||||
}
|
||||
said := strings.Join(got.lines, " ")
|
||||
if !strings.Contains(said, "the plan is done; there is nothing to retry") || !strings.Contains(said, tc.label) {
|
||||
t.Errorf("the lines do not carry the answer and the refusal's words: %q", said)
|
||||
}
|
||||
if why, ok := conditions.Plain(said); !ok {
|
||||
t.Errorf("the notice is not plain, so the channel would withhold it: %s: %q", why, said)
|
||||
}
|
||||
if len(r.acts) != 1 || !strings.HasPrefix(r.acts[0].Outcome, "failed: ") {
|
||||
t.Errorf("the failure is not recorded as before: %+v", r.acts)
|
||||
}
|
||||
if kept := r.store[w.Ask]; !strings.HasPrefix(kept.Acted, "failed: ") {
|
||||
t.Errorf("kept %+v", kept)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 1316/AC3: an answer that is done tells nothing; and a refusal of an answer that is no plan's is not told here.
|
||||
func TestADoneAnswerTellsNothingAndOnlyAPlansRefusalIsTold(t *testing.T) {
|
||||
r := newAskerRig(t)
|
||||
c := failedPlanCondition("plan-1792433168676870614")
|
||||
r.open = []conditions.Condition{c}
|
||||
_ = r.a.reconcile(context.Background())
|
||||
answerWith(t, r, r.warrantFor(t, c.Key, "Retry"))
|
||||
if len(r.told) != 0 {
|
||||
t.Fatalf("a retry that was done told %+v", r.told)
|
||||
}
|
||||
|
||||
r = newAskerRig(t)
|
||||
r.refuses = errors.New("node-service-manager.restart refused: unit not found")
|
||||
module := conditions.Condition{Key: "module.openrazer.g14.unhealthy", Severity: conditions.Warning,
|
||||
Headline: "openrazer not working on g14", Needs: "restart its service openrazer-daemon on g14.",
|
||||
Explanation: "Needs you: restart it.", Actions: []conditions.Action{{Label: "Restart",
|
||||
Verb: "node-service-manager.restart", Machine: "g14", Level: conditions.LevelApprove,
|
||||
Arguments: map[string]string{"unit": "openrazer-daemon.service", "scope": "user"}}}}
|
||||
r.open = []conditions.Condition{module}
|
||||
_ = r.a.reconcile(context.Background())
|
||||
answerWith(t, r, r.warrantFor(t, module.Key, "Restart"))
|
||||
if len(r.told) != 0 || len(r.acts) != 1 {
|
||||
t.Fatalf("a refused restart told %+v, acts %+v", r.told, r.acts)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,6 +174,22 @@ func callAction(conn *nats.Conn) func(ctx context.Context, a conditions.Action,
|
||||
}
|
||||
}
|
||||
|
||||
// tellThrough tells the operator one plain notice through the operator channel's `tell`, over the controller's own
|
||||
// connection and grant (novox/hq ADR 0380); a holder's refusal is an error naming it.
|
||||
func tellThrough(conn *nats.Conn) func(ctx context.Context, headline string, lines []string) error {
|
||||
return func(ctx context.Context, headline string, lines []string) error {
|
||||
answer, err := link.AskMeshSeatTool(ctx, conn, "operator-channel", "tell",
|
||||
map[string]any{"headline": headline, "lines": lines}, tellWithin)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if answer.Error != "" {
|
||||
return fmt.Errorf("operator-channel.tell refused: %s", answer.Error)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// routerRecordOf reads the router's record of one of the controller's asks, under its name, and answers
|
||||
// how it ended when it did: the bucket is the one the asks seat's declarer names as its records.
|
||||
func routerRecordOf(conn *nats.Conn, inv *inventory.Inventory) func(ctx context.Context, id string) (*asks.Warrant, error) {
|
||||
@@ -576,6 +592,7 @@ func servingAsker(open *stores, conn *nats.Conn, js jetstream.JetStream, keeper
|
||||
_, err := link.RecordHandAct(ctx, conn, act)
|
||||
return err
|
||||
},
|
||||
tell: tellThrough(conn),
|
||||
routerRecord: routerRecordOf(conn, open.inventory),
|
||||
routerHere: routerHereIn(open.inventory),
|
||||
grantHeld: grantHeldIn(open, conn),
|
||||
|
||||
Reference in New Issue
Block a user