diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/bridge_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/bridge_test.go index 1749fe5a1..e27803c32 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/bridge_test.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/bridge_test.go @@ -7,6 +7,7 @@ import ( "strings" "sync" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg" sdk "git.novox.be/novox/mesh-sdk/go/delivery" ) @@ -24,7 +25,7 @@ type fakeMerges struct { manual bool failed error // said are the messages the bridge wrote on FORGE, in order. - said []sdk.Forge + said []forgemsg.Forge } func newFakeMerges(f *fakeForge) *fakeMerges { @@ -76,11 +77,11 @@ func (m *fakeMerges) Put(group string, v sdk.Merge) error { err = f.refuse } f.mu.Unlock() - msg := sdk.Forge{Repository: p.Repository, Number: p.Number, Head: p.Head.Head} + msg := forgemsg.Forge{Repository: p.Repository, Number: p.Number, Head: p.Head.Head} if err != nil { - msg.Kind, msg.Act, msg.Words = sdk.ForgeRefused, "merge", err.Error() + msg.Kind, msg.Act, msg.Words = forgemsg.ForgeRefused, "merge", err.Error() } else { - msg.Kind, msg.Merge, msg.Root = sdk.ForgeMerged, strings.Repeat("c", 40), "root" + msg.Kind, msg.Merge, msg.Root = forgemsg.ForgeMerged, strings.Repeat("c", 40), "root" msg.Trees = map[string]string{"app": "tree"} } m.say(msg) @@ -89,12 +90,12 @@ func (m *fakeMerges) Put(group string, v sdk.Merge) error { } // say writes a message on FORGE and has the gate hear it, as the runtime does. -func (m *fakeMerges) say(msg sdk.Forge) { - body, err := sdk.EncodeForge(msg) +func (m *fakeMerges) say(msg forgemsg.Forge) { + body, err := forgemsg.EncodeForge(msg) if err != nil { panic(err) } - event, err := sdk.ForgeEvent(msg.Repository, msg.Kind) + event, err := forgemsg.ForgeEvent(msg.Repository, msg.Kind) if err != nil { panic(err) } diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/bus_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/bus_test.go index d4f8777a9..44237dcb3 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/bus_test.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/bus_test.go @@ -16,6 +16,7 @@ import ( "testing" "time" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg" stdio "git.novox.be/novox/mesh-sdk/go" "git.novox.be/novox/mesh-sdk/go/asks" sdk "git.novox.be/novox/mesh-sdk/go/delivery" @@ -582,7 +583,7 @@ func TestAPassingVerdictAsksTheApproveAndItsApproveMergesWithinSecondsOverARealB t.Fatalf("the gate asked the forge to merge %d times: the bridge merges", n) } // The bridge says it merged; the mesh's source then holds the merge, in one write. - b.forgeSays(t, sdk.Forge{Kind: sdk.ForgeMerged, Repository: "novox/app", Number: 7, Head: head, Merge: strings.Repeat("b", 40), + b.forgeSays(t, forgemsg.Forge{Kind: forgemsg.ForgeMerged, Repository: "novox/app", Number: 7, Head: head, Merge: strings.Repeat("b", 40), Root: "r", Trees: map[string]string{"app": "t"}}) // The forge's own word that the pull request merged follows the bridge's (its webhook): the delivery ends merged. b.publish(t, "mesh.mod.gitea.event.pull.merged", merged("novox/app", 7, head, strings.Repeat("b", 40))) @@ -677,13 +678,13 @@ func (b *testBus) answer(t *testing.T, key, option string) time.Time { } // forgeSays is the forge bridge's message on FORGE, as its stream carries it. -func (b *testBus) forgeSays(t *testing.T, f sdk.Forge) { +func (b *testBus) forgeSays(t *testing.T, f forgemsg.Forge) { t.Helper() - raw, err := sdk.EncodeForge(f) + raw, err := forgemsg.EncodeForge(f) if err != nil { t.Fatal(err) } - event, err := sdk.ForgeEvent(f.Repository, f.Kind) + event, err := forgemsg.ForgeEvent(f.Repository, f.Kind) if err != nil { t.Fatal(err) } @@ -742,7 +743,7 @@ func TestAGroupOfTwoWithGreenChecksGetsOneAskAndOnItsApproveBothMergeOneAfterThe t.Fatalf("the second member was handed before the first merged: %+v", again.Pulls) } one := first.Pulls[0] - b.forgeSays(t, sdk.Forge{Kind: sdk.ForgeMerged, Repository: one.Repository, Number: one.Number, Head: one.Head.Head, + b.forgeSays(t, forgemsg.Forge{Kind: forgemsg.ForgeMerged, Repository: one.Repository, Number: one.Number, Head: one.Head.Head, Merge: strings.Repeat("a", 40), Root: "r", Trees: map[string]string{"x": "t"}}) var both sdk.Merge rt.until("the second member to be handed", eventWithin, func() bool { both = mergeAt(b, "feat/x"); return len(both.Pulls) == 2 }) @@ -750,7 +751,7 @@ func TestAGroupOfTwoWithGreenChecksGetsOneAskAndOnItsApproveBothMergeOneAfterThe t.Fatalf("the second merge handed is %+v", both.Pulls) } two := both.Pulls[1] - b.forgeSays(t, sdk.Forge{Kind: sdk.ForgeMerged, Repository: two.Repository, Number: two.Number, Head: two.Head.Head, + b.forgeSays(t, forgemsg.Forge{Kind: forgemsg.ForgeMerged, Repository: two.Repository, Number: two.Number, Head: two.Head.Head, Merge: strings.Repeat("b", 40), Root: "r", Trees: map[string]string{"x": "t"}}) rt.until("source.main to hold both merges", eventWithin, func() bool { e, err := b.kvs["source"].Get(ctx, sdk.SourceMainKey) diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/holder_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/holder_test.go index 0c5d84dde..3de163f4d 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/holder_test.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/holder_test.go @@ -6,7 +6,7 @@ import ( "testing" "time" - sdk "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg" ) // The holder, end to end over fakes (novox/hq ADR 0239 *How it is checked*, cut by ADR 0539 §17): a pull request's head @@ -241,9 +241,9 @@ func TestAMergedGroupEndsMergedWithSourceMainMoved(t *testing.T) { t.Fatalf("source.main is %+v, wanted both merges", w.source.main) } // A merge that leaves no module.json carries no trees (issue 1367): the gate reads it, and refuses nothing. - msg := sdk.Forge{Kind: sdk.ForgeMerged, Repository: "novox/a", Number: 9, Head: strings.Repeat("e", 40), + msg := forgemsg.Forge{Kind: forgemsg.ForgeMerged, Repository: "novox/a", Number: 9, Head: strings.Repeat("e", 40), Merge: strings.Repeat("f", 40), Root: "r"} - body, err := sdk.EncodeForge(msg) + body, err := forgemsg.EncodeForge(msg) if err != nil { t.Fatalf("a merged message with no trees is refused: %v", err) } @@ -251,6 +251,17 @@ func TestAMergedGroupEndsMergedWithSourceMainMoved(t *testing.T) { if len(w.h.refusals) != 0 { t.Errorf("the gate refused a merged message with no trees: %v", w.h.refusals) } + // A group's end is the indexer's: the gate ignores it before decoding, and says no refusal (1585/T25). + w.h.Forged("mesh-delivery-forge-bridge.forge.group.merged", []byte(`{"v":1,"group":"feat/y"}`)) + if len(w.h.refusals) != 0 { + t.Errorf("the gate refused a group's end: %v", w.h.refusals) + } + // The repository forge/group's merged message ends the same way: it is decoded as a forge message, and this one, + // which is none, is refused. + w.h.Forged("mesh-delivery-forge-bridge.forge.forge.group.merged", []byte(`{"v":1,"group":"feat/y"}`)) + if len(w.h.refusals) != 1 { + t.Errorf("forge/group's merged message was passed by as a group's end: refusals %v", w.h.refusals) + } } // ADR 0419 §2, issue 978: a description's `after:` line is not read, and says so once on its pull request. The diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue1275_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue1275_test.go index 73d88678f..b6143c64c 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue1275_test.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue1275_test.go @@ -8,6 +8,7 @@ import ( "testing" "time" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg" "git.novox.be/novox/mesh-sdk/go/asks" sdk "git.novox.be/novox/mesh-sdk/go/delivery" ) @@ -765,6 +766,6 @@ func allGreenFor(w *world, ms ...member) { // bridgeMerged is the forge bridge's word that it merged a member. func bridgeMerged(w *world, repository string, number int, head, merge string) { - w.merges.say(sdk.Forge{Kind: sdk.ForgeMerged, Repository: repository, Number: number, Head: head, Merge: merge, Root: "root", + w.merges.say(forgemsg.Forge{Kind: forgemsg.ForgeMerged, Repository: repository, Number: number, Head: head, Merge: merge, Root: "root", Trees: map[string]string{"app": "tree"}}) } diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue1348_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue1348_test.go index 98a9cac5b..aebe95ed6 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue1348_test.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue1348_test.go @@ -5,6 +5,7 @@ import ( "testing" "time" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg" "git.novox.be/novox/mesh-sdk/go/asks" sdk "git.novox.be/novox/mesh-sdk/go/delivery" ) @@ -100,7 +101,7 @@ func TestAnAnswerHandsTheFirstMemberOnlyAndTheBridgesMergedHandsTheNext(t *testi if len(w.merges.puts) != 1 { t.Fatalf("the next member was handed before the bridge said the first merged: %d merges written", len(w.merges.puts)) } - w.merges.say(sdk.Forge{Kind: sdk.ForgeMerged, Repository: gCat, Number: 2, Head: gHk, Merge: strings.Repeat("a", 40), Root: "r", Trees: map[string]string{"x": "t"}}) + w.merges.say(forgemsg.Forge{Kind: forgemsg.ForgeMerged, Repository: gCat, Number: 2, Head: gHk, Merge: strings.Repeat("a", 40), Root: "r", Trees: map[string]string{"x": "t"}}) w.settleAll() if len(w.merges.puts) != 2 { t.Fatalf("the second member was not handed after the first merged: %d merges written", len(w.merges.puts)) @@ -115,7 +116,7 @@ func TestAnAnswerHandsTheFirstMemberOnlyAndTheBridgesMergedHandsTheNext(t *testi // AC2: the bridge's refused hands no member, and the group is said urgent, naming the member and the forge's words. func TestTheBridgesRefusalHandsNoMemberAndIsSaidUrgent(t *testing.T) { w := answeredFirst(t) - w.merges.say(sdk.Forge{Kind: sdk.ForgeRefused, Repository: gCat, Number: 2, Head: gHk, Act: "merge", + w.merges.say(forgemsg.Forge{Kind: forgemsg.ForgeRefused, Repository: gCat, Number: 2, Head: gHk, Act: "merge", Words: "the forge refused gitea_merge_pull_request: mesh/repo-check (missing)"}) w.later(time30s) w.settleAll() @@ -137,7 +138,7 @@ func TestTheBridgesRefusalHandsNoMemberAndIsSaidUrgent(t *testing.T) { // the group, even after the first member merged. func TestTheNextMemberIsNotHandedWhileARequiredStatusOfTheGroupIsNotASuccess(t *testing.T) { w := answeredFirst(t) - w.merges.say(sdk.Forge{Kind: sdk.ForgeMerged, Repository: gCat, Number: 2, Head: gHk, Merge: strings.Repeat("a", 40), Root: "r", Trees: map[string]string{"x": "t"}}) + w.merges.say(forgemsg.Forge{Kind: forgemsg.ForgeMerged, Repository: gCat, Number: 2, Head: gHk, Merge: strings.Repeat("a", 40), Root: "r", Trees: map[string]string{"x": "t"}}) setChecks(w, gCtl, gHc, "success", "") // the second member's repository check went missing meanwhile w.settleAll() if len(w.merges.puts) != 1 { @@ -193,7 +194,7 @@ func TestARefusalThatRepeatsWithNoTrunkMoveIsNotAskedAgainWithoutEnd(t *testing. w := answeredFirst(t) d := w.h.deliveries[IDOf(gCat, gHk)] refuse := func(took string) { - w.h.ForgeRefused(sdk.Forge{Kind: sdk.ForgeRefused, Repository: gCat, Number: 2, Head: gHk, Act: "merge", + w.h.ForgeRefused(forgemsg.Forge{Kind: forgemsg.ForgeRefused, Repository: gCat, Number: 2, Head: gHk, Act: "merge", Words: "novox/mesh-catalog#2 is not merged: its verdict names no trunk commit it was judged against, and main is now f70a99dbf485. " + "The check of head d2d2d2d2d2d2 merged into trunk f70a99dbf485 was asked; the last check took " + took + ". Ask again once both statuses pass"}) } diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/merges.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/merges.go index d2634300b..a5212a7bf 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/merges.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/merges.go @@ -10,6 +10,7 @@ import ( "strings" "time" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg" stdio "git.novox.be/novox/mesh-sdk/go" sdk "git.novox.be/novox/mesh-sdk/go/delivery" ) @@ -312,23 +313,33 @@ func (h *Holder) mergeValue(name, key string, approved map[string]string, target return m, len(m.Pulls) > 0 && state == sdk.MergeMerged } +// forgeGroupEnd is the whole event of a group's end on FORGE, the forge bridge's event name under its module: compared +// exactly, so the merged message of a repository named forge/group, which ends the same way, is read as one. +const forgeGroupEnd = sdk.ForgeBridgeModule + "." + forgemsg.ForgeGroupEndEvent + // Forged takes a message of the forge bridge on FORGE: the gate follows a merge and the refusal of a merge, and nothing else. +// +// The end of a group's merges (`forge.group.merged`, a body this gate has no use for: the gate wrote the merges itself) is +// the indexer's, and is ignored before it is decoded, so no refusal is said for it (issue 1585, ADR 0633 clause 4). func (h *Holder) Forged(event string, body []byte) { - f, err := sdk.DecodeForge(body) + if event == forgeGroupEnd { + return + } + f, err := forgemsg.DecodeForge(body) if err != nil { h.Refuse(fmt.Errorf("a message of the forge bridge is not readable: %v", err)) return } switch { - case f.Kind == sdk.ForgeMerged: + case f.Kind == forgemsg.ForgeMerged: h.ForgeMerged(f) - case f.Kind == sdk.ForgeRefused && f.Act == "merge": + case f.Kind == forgemsg.ForgeRefused && f.Act == "merge": h.ForgeRefused(f) } } // memberOfForge is the delivery a forge message is about: at the head it names. -func (h *Holder) memberOfForge(f sdk.Forge) *Delivery { +func (h *Holder) memberOfForge(f forgemsg.Forge) *Delivery { if d := h.deliveries[IDOf(f.Repository, f.Head)]; d != nil && d.Number == f.Number { return d } @@ -337,7 +348,7 @@ func (h *Holder) memberOfForge(f sdk.Forge) *Delivery { // ForgeMerged takes the bridge's word that a pull request the gate handed to it merged: the merge is announced once, // with the ask, its asker the teller and the heads (issue 1025), and the next member's turn comes at the next look. -func (h *Holder) ForgeMerged(f sdk.Forge) { +func (h *Holder) ForgeMerged(f forgemsg.Forge) { h.mu.Lock() defer h.mu.Unlock() d := h.memberOfForge(f) @@ -354,7 +365,7 @@ func (h *Holder) ForgeMerged(f sdk.Forge) { // ForgeRefused takes the bridge's word that the forge refused a merge: nothing after it is merged, and the refusal is said // as a stalled line (ADR 0441 §3, §4). The merge is handed again only at the next event that decides whether it may // merge (mergeAgainNow). -func (h *Holder) ForgeRefused(f sdk.Forge) { +func (h *Holder) ForgeRefused(f forgemsg.Forge) { h.mu.Lock() defer h.mu.Unlock() d := h.memberOfForge(f) diff --git a/modules/mesh-delivery-gate/go.mod b/modules/mesh-delivery-gate/go.mod index c0544f393..c3c093abf 100644 --- a/modules/mesh-delivery-gate/go.mod +++ b/modules/mesh-delivery-gate/go.mod @@ -3,7 +3,8 @@ module mesh-delivery-gate go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929 + git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg v0.0.0-20261104235927-c78941e80b59 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104002338-5a38a1221659 github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 ) diff --git a/modules/mesh-delivery-gate/go.sum b/modules/mesh-delivery-gate/go.sum index abad29696..be189a5d7 100644 --- a/modules/mesh-delivery-gate/go.sum +++ b/modules/mesh-delivery-gate/go.sum @@ -1,5 +1,7 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929 h1:RtAFbUJRP1qO9AIMM2ue+cXNzqj7WZ7IOlSqNvttwuU= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg v0.0.0-20261104235927-c78941e80b59 h1:pFb+lP0FxS3bF6N3gHKtjzyixI9b1vC/NLViT/lVzRQ= +git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg v0.0.0-20261104235927-c78941e80b59/go.mod h1:Xxg9VI4y755tznAS2PygQFe2V3/aIzjRn3/06+Br9nQ= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104002338-5a38a1221659 h1:tK7MzxVm/ECNg1Yblhh1ynVEthbZCg2mSPW0wQZC0b0= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104002338-5a38a1221659/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= diff --git a/modules/messenger/cmd/messenger/asks.go b/modules/messenger/cmd/messenger/asks.go index bc8dfc344..86ac84332 100644 --- a/modules/messenger/cmd/messenger/asks.go +++ b/modules/messenger/cmd/messenger/asks.go @@ -1416,3 +1416,76 @@ func (h *Holder) AsksNow() []AskView { }) return out } + +// AsksQuery narrows what the tool `operator-channel.asks` answers (issue 595): Key one ask, State open (open and held) or +// ended, Since the asks opened at or after that time, Bound the most bytes of the answer's asks. The zero query keeps the +// whole answer, as before. +type AsksQuery struct { + Key, State string + Since time.Time + Bound int +} + +// AsksAnswer is the answer of a narrowed query: the asks kept, how many the filters matched, and, when the bound cut the +// answer short, that it did and how. +type AsksAnswer struct { + Asks []AskView `json:"asks"` + Matched int `json:"matched"` + Cut string `json:"cut,omitempty"` +} + +// AsksQueryOf reads the arguments of the tool; an empty set is the zero query. +func AsksQueryOf(a map[string]any) (AsksQuery, error) { + q := AsksQuery{Key: strArg(a, "key"), State: strArg(a, "state")} + if q.State != "" && q.State != "open" && q.State != "ended" { + return q, errorf("state is open or ended, not %q", q.State) + } + if since := strArg(a, "since"); since != "" { + t, err := time.Parse(time.RFC3339, since) + if err != nil { + return q, errorf("since is a time like 2026-11-04T22:00:00Z: %v", err) + } + q.Since = t + } + if v, ok := a["bound"].(float64); ok { + if v < 1 { + return q, errorf("bound is the most bytes of the answer, at least 1") + } + q.Bound = int(v) + } + return q, nil +} + +// Narrow keeps of the views (open first, newest first) the asks the query names, and cuts the answer at its byte bound: the +// asks that fit, in order, and a said cut when one more did not. +func (q AsksQuery) Narrow(all []AskView) AsksAnswer { + var out AsksAnswer + out.Asks = []AskView{} + var kept []AskView + for _, v := range all { + open := v.State == StateOpen || v.State == StateHeld + switch { + case q.Key != "" && v.Key != q.Key, + q.State == "open" && !open, + q.State == "ended" && open, + !q.Since.IsZero() && v.Opened < q.Since.UTC().Format(time.RFC3339): + continue + } + kept = append(kept, v) + } + out.Matched = len(kept) + used := 0 + for i, v := range kept { + if q.Bound > 0 { + raw, _ := json.Marshal(v) + if used+len(raw)+1 > q.Bound { + out.Cut = fmt.Sprintf("the answer is cut at its bound of %d bytes: %d of %d asks are shown, the %d after them are not "+ + "(narrow with key, state or since, or raise bound)", q.Bound, i, len(kept), len(kept)-i) + break + } + used += len(raw) + 1 + } + out.Asks = append(out.Asks, v) + } + return out +} diff --git a/modules/messenger/cmd/messenger/held_test.go b/modules/messenger/cmd/messenger/held_test.go index 1efae214f..2d6c74c1e 100644 --- a/modules/messenger/cmd/messenger/held_test.go +++ b/modules/messenger/cmd/messenger/held_test.go @@ -2,6 +2,7 @@ package main import ( "encoding/json" + "reflect" "strings" "testing" "time" @@ -140,3 +141,55 @@ func TestAHeldAskNamesEveryKindAndWhyWhenNoChannelIsReady(t *testing.T) { t.Fatalf("%q", why) } } + +// Issue 595: operator-channel.asks answers one ask, the open or the ended, those opened from a time, and no more bytes than a +// bound, said when it cut the answer short; with no argument it keeps the whole answer. +func TestTheAsksVerbNarrowsItsAnswer(t *testing.T) { + view := func(key, state, opened string) AskView { + return AskView{Key: key, Asker: "mesh-delivery", Headline: "Approve " + key, State: state, Opened: opened} + } + all := []AskView{view("a.3", StateOpen, "2026-11-04T10:00:00Z"), view("a.2", StateHeld, "2026-11-03T10:00:00Z"), + view("a.1", "ended", "2026-11-02T10:00:00Z"), view("a.0", "ended", "2026-11-01T10:00:00Z")} + one, _ := json.Marshal(all[0]) + for _, tc := range []struct { + name string + args map[string]any + want []string + matched int + cut bool + err bool + }{ + {"no argument is the zero query", map[string]any{}, nil, 0, false, false}, + {"key", map[string]any{"key": "a.1"}, []string{"a.1"}, 1, false, false}, + {"state open takes open and held", map[string]any{"state": "open"}, []string{"a.3", "a.2"}, 2, false, false}, + {"state ended", map[string]any{"state": "ended"}, []string{"a.1", "a.0"}, 2, false, false}, + {"since", map[string]any{"since": "2026-11-02T12:00:00Z"}, []string{"a.3", "a.2"}, 2, false, false}, + {"bound cuts and says so", map[string]any{"bound": float64(2*len(one) + 10), "state": "open"}, []string{"a.3", "a.2"}, 2, false, false}, + {"a bound under the asks cuts", map[string]any{"bound": float64(len(one) + 5)}, []string{"a.3"}, 4, true, false}, + {"a state that is neither", map[string]any{"state": "done"}, nil, 0, false, true}, + {"a time that is not one", map[string]any{"since": "yesterday"}, nil, 0, false, true}, + } { + q, err := AsksQueryOf(tc.args) + if (err != nil) != tc.err { + t.Errorf("%s: err = %v", tc.name, err) + continue + } + if tc.err { + continue + } + if tc.want == nil { + if q != (AsksQuery{}) { + t.Errorf("%s: %+v is not the zero query, which keeps today's answer", tc.name, q) + } + continue + } + got := q.Narrow(all) + var keys []string + for _, v := range got.Asks { + keys = append(keys, v.Key) + } + if !reflect.DeepEqual(keys, tc.want) || got.Matched != tc.matched || (got.Cut != "") != tc.cut { + t.Errorf("%s: asks %v matched %d cut %q, want %v, %d, cut %v", tc.name, keys, got.Matched, got.Cut, tc.want, tc.matched, tc.cut) + } + } +} diff --git a/modules/messenger/cmd/messenger/main.go b/modules/messenger/cmd/messenger/main.go index f237289a1..d1346ef97 100644 --- a/modules/messenger/cmd/messenger/main.go +++ b/modules/messenger/cmd/messenger/main.go @@ -734,8 +734,24 @@ func tools(h *Holder, l *listening) []stdio.Tool { {Name: "operator-channel.asks", Description: "The asks this router holds (novox/hq ADR 0259): open (or held, with since when and why no channel can carry it yet) first, then the last month's — each " + "one's asker, headline, state, when it opened and expires, the channels it was offered on, how it " + - "ended (the warrant: who chose what, through which channel), and whether its asker was told.", - Run: func(map[string]any) (any, error) { return h.AsksNow(), nil }}, + "ended (the warrant: who chose what, through which channel), and whether its asker was told. With no argument every " + + "ask, which is large; filter it: key for one ask, state open or ended, since (a time, RFC 3339) for the asks opened " + + "from then, and bound, the most bytes of the answer, which says when it cut it short.", + Input: map[string]any{ + "key": str("one ask, by its key (asker.id); none for every ask"), + "state": str("open (open and held) or ended; none for both"), + "since": str("only the asks opened at or after this time, RFC 3339"), + "bound": map[string]any{"type": "integer", "description": "the most bytes of the asks in the answer; said when it cuts the answer short"}}, + Run: func(a map[string]any) (any, error) { + q, err := AsksQueryOf(a) + if err != nil { + return nil, err + } + if q == (AsksQuery{}) { + return h.AsksNow(), nil + } + return q.Narrow(h.AsksNow()), nil + }}, {Name: "messenger_identities", Description: "The accounts linked as the operator, one per channel kind: the account, the name its " + "service shows, when it was linked, and from when it can approve (an hour after linking).",