Merge pull request 'Gate reads the forge bridge's own messages and passes a group's end by; operator-channel.asks narrows its answer (issues 1585, 595; ADR 0633)' (#615) from feat/take-in into main

This commit was merged in pull request #615.
This commit is contained in:
2026-11-05 00:33:41 +00:00
11 changed files with 203 additions and 32 deletions
@@ -7,6 +7,7 @@ import (
"strings" "strings"
"sync" "sync"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg"
sdk "git.novox.be/novox/mesh-sdk/go/delivery" sdk "git.novox.be/novox/mesh-sdk/go/delivery"
) )
@@ -24,7 +25,7 @@ type fakeMerges struct {
manual bool manual bool
failed error failed error
// said are the messages the bridge wrote on FORGE, in order. // said are the messages the bridge wrote on FORGE, in order.
said []sdk.Forge said []forgemsg.Forge
} }
func newFakeMerges(f *fakeForge) *fakeMerges { func newFakeMerges(f *fakeForge) *fakeMerges {
@@ -76,11 +77,11 @@ func (m *fakeMerges) Put(group string, v sdk.Merge) error {
err = f.refuse err = f.refuse
} }
f.mu.Unlock() 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 { 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 { } 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"} msg.Trees = map[string]string{"app": "tree"}
} }
m.say(msg) 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. // say writes a message on FORGE and has the gate hear it, as the runtime does.
func (m *fakeMerges) say(msg sdk.Forge) { func (m *fakeMerges) say(msg forgemsg.Forge) {
body, err := sdk.EncodeForge(msg) body, err := forgemsg.EncodeForge(msg)
if err != nil { if err != nil {
panic(err) panic(err)
} }
event, err := sdk.ForgeEvent(msg.Repository, msg.Kind) event, err := forgemsg.ForgeEvent(msg.Repository, msg.Kind)
if err != nil { if err != nil {
panic(err) panic(err)
} }
@@ -16,6 +16,7 @@ import (
"testing" "testing"
"time" "time"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg"
stdio "git.novox.be/novox/mesh-sdk/go" stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/asks" "git.novox.be/novox/mesh-sdk/go/asks"
sdk "git.novox.be/novox/mesh-sdk/go/delivery" 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) 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. // 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"}}) 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. // 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))) 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. // 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() t.Helper()
raw, err := sdk.EncodeForge(f) raw, err := forgemsg.EncodeForge(f)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
event, err := sdk.ForgeEvent(f.Repository, f.Kind) event, err := forgemsg.ForgeEvent(f.Repository, f.Kind)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -742,7 +743,7 @@ func TestAGroupOfTwoWithGreenChecksGetsOneAskAndOnItsApproveBothMergeOneAfterThe
t.Fatalf("the second member was handed before the first merged: %+v", again.Pulls) t.Fatalf("the second member was handed before the first merged: %+v", again.Pulls)
} }
one := first.Pulls[0] 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"}}) Merge: strings.Repeat("a", 40), Root: "r", Trees: map[string]string{"x": "t"}})
var both sdk.Merge 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 }) 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) t.Fatalf("the second merge handed is %+v", both.Pulls)
} }
two := both.Pulls[1] 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"}}) Merge: strings.Repeat("b", 40), Root: "r", Trees: map[string]string{"x": "t"}})
rt.until("source.main to hold both merges", eventWithin, func() bool { rt.until("source.main to hold both merges", eventWithin, func() bool {
e, err := b.kvs["source"].Get(ctx, sdk.SourceMainKey) e, err := b.kvs["source"].Get(ctx, sdk.SourceMainKey)
@@ -6,7 +6,7 @@ import (
"testing" "testing"
"time" "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 // 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) 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. // 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"} Merge: strings.Repeat("f", 40), Root: "r"}
body, err := sdk.EncodeForge(msg) body, err := forgemsg.EncodeForge(msg)
if err != nil { if err != nil {
t.Fatalf("a merged message with no trees is refused: %v", err) 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 { if len(w.h.refusals) != 0 {
t.Errorf("the gate refused a merged message with no trees: %v", w.h.refusals) 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 // ADR 0419 §2, issue 978: a description's `after:` line is not read, and says so once on its pull request. The
@@ -8,6 +8,7 @@ import (
"testing" "testing"
"time" "time"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg"
"git.novox.be/novox/mesh-sdk/go/asks" "git.novox.be/novox/mesh-sdk/go/asks"
sdk "git.novox.be/novox/mesh-sdk/go/delivery" 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. // bridgeMerged is the forge bridge's word that it merged a member.
func bridgeMerged(w *world, repository string, number int, head, merge string) { 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"}}) Trees: map[string]string{"app": "tree"}})
} }
@@ -5,6 +5,7 @@ import (
"testing" "testing"
"time" "time"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg"
"git.novox.be/novox/mesh-sdk/go/asks" "git.novox.be/novox/mesh-sdk/go/asks"
sdk "git.novox.be/novox/mesh-sdk/go/delivery" sdk "git.novox.be/novox/mesh-sdk/go/delivery"
) )
@@ -100,7 +101,7 @@ func TestAnAnswerHandsTheFirstMemberOnlyAndTheBridgesMergedHandsTheNext(t *testi
if len(w.merges.puts) != 1 { 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)) 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() w.settleAll()
if len(w.merges.puts) != 2 { 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)) 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. // 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) { func TestTheBridgesRefusalHandsNoMemberAndIsSaidUrgent(t *testing.T) {
w := answeredFirst(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)"}) Words: "the forge refused gitea_merge_pull_request: mesh/repo-check (missing)"})
w.later(time30s) w.later(time30s)
w.settleAll() w.settleAll()
@@ -137,7 +138,7 @@ func TestTheBridgesRefusalHandsNoMemberAndIsSaidUrgent(t *testing.T) {
// the group, even after the first member merged. // the group, even after the first member merged.
func TestTheNextMemberIsNotHandedWhileARequiredStatusOfTheGroupIsNotASuccess(t *testing.T) { func TestTheNextMemberIsNotHandedWhileARequiredStatusOfTheGroupIsNotASuccess(t *testing.T) {
w := answeredFirst(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 setChecks(w, gCtl, gHc, "success", "") // the second member's repository check went missing meanwhile
w.settleAll() w.settleAll()
if len(w.merges.puts) != 1 { if len(w.merges.puts) != 1 {
@@ -193,7 +194,7 @@ func TestARefusalThatRepeatsWithNoTrunkMoveIsNotAskedAgainWithoutEnd(t *testing.
w := answeredFirst(t) w := answeredFirst(t)
d := w.h.deliveries[IDOf(gCat, gHk)] d := w.h.deliveries[IDOf(gCat, gHk)]
refuse := func(took string) { 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. " + 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"}) "The check of head d2d2d2d2d2d2 merged into trunk f70a99dbf485 was asked; the last check took " + took + ". Ask again once both statuses pass"})
} }
@@ -10,6 +10,7 @@ import (
"strings" "strings"
"time" "time"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg"
stdio "git.novox.be/novox/mesh-sdk/go" stdio "git.novox.be/novox/mesh-sdk/go"
sdk "git.novox.be/novox/mesh-sdk/go/delivery" 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 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. // 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) { 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 { if err != nil {
h.Refuse(fmt.Errorf("a message of the forge bridge is not readable: %v", err)) h.Refuse(fmt.Errorf("a message of the forge bridge is not readable: %v", err))
return return
} }
switch { switch {
case f.Kind == sdk.ForgeMerged: case f.Kind == forgemsg.ForgeMerged:
h.ForgeMerged(f) h.ForgeMerged(f)
case f.Kind == sdk.ForgeRefused && f.Act == "merge": case f.Kind == forgemsg.ForgeRefused && f.Act == "merge":
h.ForgeRefused(f) h.ForgeRefused(f)
} }
} }
// memberOfForge is the delivery a forge message is about: at the head it names. // 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 { if d := h.deliveries[IDOf(f.Repository, f.Head)]; d != nil && d.Number == f.Number {
return d 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, // 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. // 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() h.mu.Lock()
defer h.mu.Unlock() defer h.mu.Unlock()
d := h.memberOfForge(f) 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 // 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 // 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). // merge (mergeAgainNow).
func (h *Holder) ForgeRefused(f sdk.Forge) { func (h *Holder) ForgeRefused(f forgemsg.Forge) {
h.mu.Lock() h.mu.Lock()
defer h.mu.Unlock() defer h.mu.Unlock()
d := h.memberOfForge(f) d := h.memberOfForge(f)
+2 -1
View File
@@ -3,7 +3,8 @@ module mesh-delivery-gate
go 1.26.0 go 1.26.0
require ( 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-server/v2 v2.11.17
github.com/nats-io/nats.go v1.54.0 github.com/nats-io/nats.go v1.54.0
) )
+4 -2
View File
@@ -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-delivery/modules/mesh-delivery-forge-bridge/forgemsg v0.0.0-20261104235927-c78941e80b59 h1:pFb+lP0FxS3bF6N3gHKtjzyixI9b1vC/NLViT/lVzRQ=
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/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 h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= 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= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
+73
View File
@@ -1416,3 +1416,76 @@ func (h *Holder) AsksNow() []AskView {
}) })
return out 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
}
@@ -2,6 +2,7 @@ package main
import ( import (
"encoding/json" "encoding/json"
"reflect"
"strings" "strings"
"testing" "testing"
"time" "time"
@@ -140,3 +141,55 @@ func TestAHeldAskNamesEveryKindAndWhyWhenNoChannelIsReady(t *testing.T) {
t.Fatalf("%q", why) 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)
}
}
}
+18 -2
View File
@@ -734,8 +734,24 @@ func tools(h *Holder, l *listening) []stdio.Tool {
{Name: "operator-channel.asks", {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 " + 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 " + "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.", "ended (the warrant: who chose what, through which channel), and whether its asker was told. With no argument every " +
Run: func(map[string]any) (any, error) { return h.AsksNow(), nil }}, "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", {Name: "messenger_identities",
Description: "The accounts linked as the operator, one per channel kind: the account, the name its " + 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).", "service shows, when it was linked, and from when it can approve (an hour after linking).",