Tell the operator when the answer to a plan's ask is refused (issue 1316, ADR 0449)
This commit is contained in:
@@ -208,6 +208,9 @@ type asker struct {
|
|||||||
// raise keeps the asker's own condition (sourceAsker): which conditions needing the operator could not be
|
// 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).
|
// asked, and why. Nil raises nothing (a test that does not look).
|
||||||
raise func(ctx context.Context, obs []conditions.Observation) error
|
raise func(ctx context.Context, obs []conditions.Observation) error
|
||||||
|
// 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
|
now func() time.Time
|
||||||
logf func(string, ...any)
|
logf func(string, ...any)
|
||||||
|
|
||||||
@@ -886,6 +889,7 @@ func (a *asker) Decided(ctx context.Context, body []byte) error {
|
|||||||
ended := a.now()
|
ended := a.now()
|
||||||
if acted != nil {
|
if acted != nil {
|
||||||
outcome = "failed: " + acted.Error()
|
outcome = "failed: " + acted.Error()
|
||||||
|
a.tellRefused(ctx, *r, act, acted)
|
||||||
}
|
}
|
||||||
r.Ended, r.Acted = ended, outcome
|
r.Ended, r.Acted = ended, outcome
|
||||||
if _, err := a.store.Change(ctx, r.ID, func(x *asked) bool {
|
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
|
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,
|
// handActWarrant is the verb an act the operator chose on a warrant is recorded under: a person's decision,
|
||||||
// never a repair (handActVerbs).
|
// never a repair (handActVerbs).
|
||||||
const handActWarrant = "warrant"
|
const handActWarrant = "warrant"
|
||||||
|
|||||||
@@ -77,6 +77,15 @@ type askerRig struct {
|
|||||||
silenced []string
|
silenced []string
|
||||||
acts []link.HandAct
|
acts []link.HandAct
|
||||||
now time.Time
|
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 {
|
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 {
|
call: func(_ context.Context, a conditions.Action, args map[string]string) error {
|
||||||
raw, _ := json.Marshal(args)
|
raw, _ := json.Marshal(args)
|
||||||
r.called = append(r.called, a.Verb+"@"+a.Machine+" "+string(raw))
|
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
|
return nil
|
||||||
},
|
},
|
||||||
record: func(_ context.Context, act link.HandAct) error { r.acts = append(r.acts, act); 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)
|
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
|
// 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.
|
// 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) {
|
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)
|
_, err := link.RecordHandAct(ctx, conn, act)
|
||||||
return err
|
return err
|
||||||
},
|
},
|
||||||
|
tell: tellThrough(conn),
|
||||||
routerRecord: routerRecordOf(conn, open.inventory),
|
routerRecord: routerRecordOf(conn, open.inventory),
|
||||||
routerHere: routerHereIn(open.inventory),
|
routerHere: routerHereIn(open.inventory),
|
||||||
grantHeld: grantHeldIn(open, conn),
|
grantHeld: grantHeldIn(open, conn),
|
||||||
|
|||||||
Reference in New Issue
Block a user