Give the command an answer verb, so the operator answers a hold or restore ask from a terminal as in the page (issue 1297 B9b)

This commit is contained in:
2026-10-23 15:18:04 +02:00
parent f7dd4ab586
commit e7ed48b81a
2 changed files with 87 additions and 3 deletions
@@ -1,5 +1,6 @@
// Command mesh-delivery-cli is the operator's command for the delivery view (novox/hq ADR 0429 §3). It asks the
// view's local socket for the rows and prints them; it holds no credential and changes nothing.
// view's local socket for the rows and prints them; it holds no credential. `answer <ask key> <option> [words]` asks the view
// to write the operator's answer to an ask that needs no proof of who answered (the view refuses the merge's Approve).
package main
import (
@@ -8,6 +9,7 @@ import (
"fmt"
"io"
"os"
"strings"
"mesh-delivery-view/internal/view"
)
@@ -30,8 +32,17 @@ func run(args []string, socket string, out, errOut io.Writer) int {
return 2
}
if fs.NArg() > 0 {
fmt.Fprintln(errOut, "mesh-delivery-cli takes no argument: it only shows")
return 2
if fs.Arg(0) != "answer" || fs.NArg() < 3 {
fmt.Fprintln(errOut, "mesh-delivery-cli shows the rows, or answers: mesh-delivery-cli answer <ask key> <option> [words]")
return 2
}
key, option := fs.Arg(1), fs.Arg(2)
if err := view.AnswerAsk(socket, key, option, strings.Join(fs.Args()[3:], " ")); err != nil {
fmt.Fprintln(errOut, "mesh-delivery-cli:", err)
return 1
}
fmt.Fprintf(out, "answered %s with %s; the teller fills the ask from it\n", key, option)
return 0
}
s, err := view.Ask(socket)
if err != nil {
@@ -167,3 +167,76 @@ func TestPrintsTheOpenAsksInTheTableAndTheJSON(t *testing.T) { // 1345/AC2
t.Errorf("json = %s (%v)", out.String(), err)
}
}
type putter struct{ puts map[string][]byte }
func (p *putter) Put(key string, value []byte) error { p.puts[key] = value; return nil }
func serveWith(t *testing.T, m *view.Model) string {
t.Helper()
dir, err := os.MkdirTemp(os.Getenv("TMPDIR"), "c")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { os.RemoveAll(dir) })
l, err := view.ListenSocket(filepath.Join(dir, "cli.sock"))
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
go view.ServeSocket(ctx, l, m)
t.Cleanup(cancel)
return filepath.Join(dir, "cli.sock")
}
// TestTheAnswerCommandAnswersAnAskNeedingNoProof: `answer <ask key> <option> [words]` writes the view's answer; the merge's
// ask is refused with the phone as the reason (1297/B9b).
func TestTheAnswerCommandAnswersAnAskNeedingNoProof(t *testing.T) { // 1297/B9b AC
const fp = "src2:2222222222222222222222222222222222222222222222222222222222222222"
key, err := delivery.HoldAskKey("plex", fp)
if err != nil {
t.Fatal(err)
}
hold := asks.Ask{ID: delivery.AskID(key), Headline: "plex is held", Explanation: "ace failed the check.", Who: asks.Operator, Expires: time.Now().Add(time.Hour), OnExpiry: "stays held",
Options: []asks.Option{{ID: "release", Label: "Release", Does: "goes on", Level: asks.Approve}, {ID: "keep", Label: "Keep", Does: "stays", Level: asks.Approve}}}
value, _ := json.Marshal(map[string]any{"v": 1, "ask": hold})
heads := []delivery.Head{{Repository: "novox/mesh-delivery", Number: 12, Head: "0123456789abcdef0123456789abcdef01234567"}}
mkey, err := delivery.MergeAskKey("feat/x", heads)
if err != nil {
t.Fatal(err)
}
merge := asks.Ask{ID: delivery.AskID(mkey), Headline: "Merge", Explanation: "Merges.", Who: asks.Operator, Expires: time.Now().Add(time.Hour), OnExpiry: "held",
Options: []asks.Option{{ID: "approve", Label: "Approve", Does: "merges", Level: asks.Approve, Binds: "sha256:00"}}}
mv, err := delivery.EncodeAsk(delivery.Ask{Group: "feat/x", Heads: heads, Ask: merge})
if err != nil {
t.Fatal(err)
}
m, w := view.New(), &putter{puts: map[string][]byte{}}
m.SetWriter(w)
m.Apply(view.Entry{Bucket: delivery.AssignerAsksBucket, Key: key, Value: value})
m.Apply(view.Entry{Bucket: delivery.AsksBucket, Key: mkey, Value: mv})
socket := serveWith(t, m)
var out, errOut bytes.Buffer
if code := run([]string{"answer", key, "release", "the", "check", "was", "a", "flake"}, socket, &out, &errOut); code != 0 {
t.Fatalf("exit %d: %s", code, errOut.String())
}
a, err := delivery.DecodeViewAnswer(w.puts[key+".view"])
if err != nil || a.Option != "release" || a.Words != "the check was a flake" || !strings.Contains(out.String(), key) {
t.Errorf("answer = %+v, %v; out %q", a, err, out.String())
}
out.Reset()
errOut.Reset()
if code := run([]string{"answer", mkey, "approve"}, socket, &out, &errOut); code != 1 || !strings.Contains(errOut.String(), "answer it on the phone (issue 953)") {
t.Errorf("merge: exit %d, stderr %q", code, errOut.String())
}
if len(w.puts) != 1 {
t.Errorf("puts = %v, want only the hold's", w.puts)
}
for _, args := range [][]string{{"answer"}, {"answer", key}, {"settle", key, "release"}} {
errOut.Reset()
if code := run(args, socket, &out, &errOut); code != 2 || errOut.Len() == 0 {
t.Errorf("%v: exit %d, stderr %q", args, code, errOut.String())
}
}
}