Main reverted G's half merge (#189, reverted by8afc3ebin #191) and then took the reader release's S1-S5, which copied G's code. G's form holds where they differ (process_bus.go, seats.go, state.go and their tests); the release's kept names stay so main's callers keep building: SeatViewPrefix beside SeatReadPrefix, and QueueInvoke's empty strings on not-ok (with its test TestQueueInvoke). The tree differs from G's headf30dee54only by those and the release's two tests (objects cap option, seat states and stream kinds). Builds: design 47 G (the milestone's sdk part, as atf30dee54) Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
440 lines
15 KiB
Go
440 lines
15 KiB
Go
package approval
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
stdio "git.novox.be/novox/mesh-sdk/go"
|
|
"git.novox.be/novox/mesh-sdk/go/operatorask"
|
|
)
|
|
|
|
// memStore is the holder's state approvals in memory: create refuses a key that exists, update is compare-and-set.
|
|
type memStore struct {
|
|
mu sync.Mutex
|
|
vals map[string][]byte
|
|
revs map[string]uint64
|
|
rev uint64
|
|
fail error
|
|
}
|
|
|
|
func newMem() *memStore { return &memStore{vals: map[string][]byte{}, revs: map[string]uint64{}} }
|
|
|
|
func (m *memStore) Get(key string) (*stdio.StateEntry, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if m.fail != nil {
|
|
return nil, m.fail
|
|
}
|
|
v, ok := m.vals[key]
|
|
if !ok {
|
|
return nil, nil
|
|
}
|
|
return &stdio.StateEntry{Key: key, Value: v, Revision: m.revs[key]}, nil
|
|
}
|
|
|
|
func (m *memStore) write(key string, value any, check func(exists bool, rev uint64) bool) (uint64, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if m.fail != nil {
|
|
return 0, m.fail
|
|
}
|
|
_, exists := m.vals[key]
|
|
if !check(exists, m.revs[key]) {
|
|
return 0, stdio.ErrChanged
|
|
}
|
|
raw, err := json.Marshal(value)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
m.rev++
|
|
m.vals[key], m.revs[key] = raw, m.rev
|
|
return m.rev, nil
|
|
}
|
|
|
|
func (m *memStore) Create(key string, value any) (uint64, error) {
|
|
return m.write(key, value, func(exists bool, _ uint64) bool { return !exists })
|
|
}
|
|
func (m *memStore) Update(key string, value any, revision uint64) (uint64, error) {
|
|
return m.write(key, value, func(exists bool, rev uint64) bool { return exists && rev == revision })
|
|
}
|
|
func (m *memStore) Put(key string, value any) (uint64, error) {
|
|
return m.write(key, value, func(bool, uint64) bool { return true })
|
|
}
|
|
func (m *memStore) Keys() ([]string, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if m.fail != nil {
|
|
return nil, m.fail
|
|
}
|
|
var out []string
|
|
for k := range m.vals {
|
|
out = append(out, k)
|
|
}
|
|
sort.Strings(out)
|
|
return out, nil
|
|
}
|
|
|
|
// bounds is the holder's bound keys in its state approvals as a fake (A-255): each key kept in the state with its time to
|
|
// live, expire ending it as the bus does and handing it to the watch.
|
|
type bounds struct {
|
|
state *memStore
|
|
mu sync.Mutex
|
|
ttl map[string]time.Duration
|
|
watched func(string)
|
|
}
|
|
|
|
func (b *bounds) CreateTTL(key string, value any, ttl time.Duration) error {
|
|
if _, err := b.state.Create(key, value); err != nil {
|
|
return err
|
|
}
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
b.ttl[key] = ttl
|
|
return nil
|
|
}
|
|
func (b *bounds) Delete(key string) error {
|
|
b.state.delete(key)
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
delete(b.ttl, key)
|
|
return nil
|
|
}
|
|
func (b *bounds) Watch(pattern string, expired func(string)) (func(), error) {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
b.watched = expired
|
|
return func() {}, nil
|
|
}
|
|
func (b *bounds) expire(key string) {
|
|
b.mu.Lock()
|
|
_, ok := b.ttl[key]
|
|
delete(b.ttl, key)
|
|
w := b.watched
|
|
b.mu.Unlock()
|
|
b.state.delete(key)
|
|
if ok && w != nil {
|
|
w(key)
|
|
}
|
|
}
|
|
|
|
func (m *memStore) delete(key string) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
delete(m.vals, key)
|
|
delete(m.revs, key)
|
|
}
|
|
|
|
// router is the operator channel as a fake: it keeps each ask the gate raises, and the test decides its record.
|
|
type router struct {
|
|
mu sync.Mutex
|
|
state Store
|
|
asks []Ask
|
|
records map[string]*Record
|
|
}
|
|
|
|
func (r *router) Ask(module string, a Ask) error {
|
|
raw, _ := json.Marshal(a)
|
|
if _, err := r.state.Create("ask."+a.ID, kept{Ask: raw, Asked: time.Now()}); err != nil {
|
|
return err
|
|
}
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
r.asks = append(r.asks, a)
|
|
return nil
|
|
}
|
|
func (r *router) Record(module, id string) (*Record, error) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
return r.records[id], nil
|
|
}
|
|
func (r *router) Claim(module string, w Warrant, act operatorask.Act, run func()) (Claim, error) {
|
|
e, _ := r.state.Get("ask." + w.Ask)
|
|
if e == nil {
|
|
return ClaimRefused, fmt.Errorf("no ask %s", w.Ask)
|
|
}
|
|
var k kept
|
|
_ = json.Unmarshal(e.Value, &k)
|
|
if k.Acted {
|
|
return ClaimDone, nil
|
|
}
|
|
var a Ask
|
|
_ = json.Unmarshal(k.Ask, &a)
|
|
digest, _ := operatorask.ActDigest(act)
|
|
if a.About != digest {
|
|
return ClaimRefused, fmt.Errorf("the option binds another act")
|
|
}
|
|
k.Acted = true
|
|
if _, err := r.state.Update("ask."+w.Ask, k, e.Revision); err != nil {
|
|
return ClaimDone, nil
|
|
}
|
|
run()
|
|
return Claimed, nil
|
|
}
|
|
func (r *router) Cancel(module, id string) error { return nil }
|
|
func (r *router) Decided(module string) string { return "decided." + module }
|
|
func (r *router) Seat() string { return "operator-channel" }
|
|
|
|
func (r *router) choose(id, option string, at time.Time) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
r.records[id] = &Record{State: "chosen", OfferedOn: []string{"telegram"},
|
|
Warrant: &Warrant{Ask: id, Asker: "holder", Option: option, Channel: "telegram", At: formatTime(at)}}
|
|
}
|
|
|
|
type findings struct {
|
|
mu sync.Mutex
|
|
raised map[string]bool
|
|
heard chan string
|
|
}
|
|
|
|
func (f *findings) Raise(_ context.Context, kind, id, _, _ string) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.raised[kind+" "+id] = true
|
|
if f.heard != nil {
|
|
f.heard <- kind + " " + id
|
|
}
|
|
return nil
|
|
}
|
|
func (f *findings) Clear(_ context.Context, kind, id, _ string) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
delete(f.raised, kind+" "+id)
|
|
return nil
|
|
}
|
|
|
|
// Design 47 G §677 (lead answers A-158, A-209, A-244): a process holder's marked verb is one call with a gate before its
|
|
// running, its waiting calls, act keys and claims kept in its own state.
|
|
func TestAHolderHoldsAMarkedCall(t *testing.T) {
|
|
now := time.Date(2026, 11, 12, 10, 0, 0, 0, time.UTC)
|
|
type world struct {
|
|
g *Gate
|
|
state *memStore
|
|
router *router
|
|
said map[string]GatedEnded
|
|
ran *int
|
|
recs *int
|
|
finds *findings
|
|
after chan time.Time
|
|
bounds *bounds
|
|
}
|
|
newGate := func(state *memStore, r *router, ran, recs *int, said map[string]GatedEnded, after chan time.Time, f *findings, b *bounds) *Gate {
|
|
g := New(Deps{Node: "n1", Runner: "holder", Holding: true, Build: "fp1", State: state, Channel: r, Findings: f, Bounds: b,
|
|
Now: func() time.Time { return now }, Go: func(f func()) { f() },
|
|
After: func(d time.Duration) <-chan time.Time {
|
|
if d < time.Minute { // the record's short pauses pass at once
|
|
fired := make(chan time.Time)
|
|
close(fired)
|
|
return fired
|
|
}
|
|
return after
|
|
},
|
|
Warranted: func(asker, ask string, open func() (bool, error)) error {
|
|
*recs++
|
|
return nil
|
|
},
|
|
Say: func(subject string, body any, id string) error {
|
|
said[subject] = body.(GatedEnded)
|
|
return nil
|
|
}})
|
|
g.Serves("wiper", "wipe", func(caller string, args json.RawMessage) (any, error) { *ran++; return "wiped", nil })
|
|
return g
|
|
}
|
|
setup := func() world {
|
|
state := newMem()
|
|
r := &router{state: state, records: map[string]*Record{}}
|
|
ran, recs := 0, 0
|
|
w := world{state: state, router: r, said: map[string]GatedEnded{}, ran: &ran, recs: &recs,
|
|
finds: &findings{raised: map[string]bool{}}, after: make(chan time.Time), bounds: &bounds{state: state, ttl: map[string]time.Duration{}}}
|
|
w.g = newGate(state, r, w.ran, w.recs, w.said, w.after, w.finds, w.bounds)
|
|
return w
|
|
}
|
|
call := func(caller, args string) Call {
|
|
return Call{Address: "wiper.wipe", Module: "holder", Seat: "wiper", Name: "wipe", Caller: caller, Args: json.RawMessage(args)}
|
|
}
|
|
hold := func(t *testing.T, w world, c Call, wait bool) (any, *Held, error) {
|
|
return w.g.Hold(c, wait, func() (any, error) { *w.ran++; return "wiped", nil })
|
|
}
|
|
ended := func(w world, caller, id string) GatedEnded { return w.said[GatedEndedSubject("wiper", caller, id)] }
|
|
|
|
t.Run("asked, run once on the Approve, its end said to its caller", func(t *testing.T) {
|
|
w := setup()
|
|
_, h, err := hold(t, w, call("n1.caller", `{"dir":"scratch"}`), true)
|
|
if err != nil || h == nil || h.Asked == "" || h.Call == "" {
|
|
t.Fatalf("held %+v, %v", h, err)
|
|
}
|
|
if e, _ := w.state.Get("call." + h.Call); e == nil {
|
|
t.Fatal("no call kept")
|
|
}
|
|
w.router.choose(h.Asked, OptionApprove, now)
|
|
w.g.Decided(h.Asked)
|
|
w.g.Decided(h.Asked) // heard twice: run once
|
|
end := ended(w, "n1.caller", h.Call)
|
|
if *w.ran != 1 || end.Outcome != OutcomeRan || end.Answer != "wiped" || !strings.Contains(end.Approval, h.Asked) || *w.recs != 1 {
|
|
t.Fatalf("ran %d, recorded %d, end %+v", *w.ran, *w.recs, end)
|
|
}
|
|
if e, _ := w.state.Get("claimed." + h.Asked); e == nil {
|
|
t.Error("no claim kept")
|
|
}
|
|
a, _ := w.g.Approvals("someone.else")
|
|
if len(a.Calls) != 1 || a.Calls[0].Arguments != nil || a.Calls[0].State != OutcomeRan || len(a.Acts) != 1 || len(a.Claims) != 1 {
|
|
t.Errorf("approvals %+v", a)
|
|
}
|
|
if a, _ := w.g.Approvals("n1.caller"); string(a.Calls[0].Arguments) != `{"dir":"scratch"}` {
|
|
t.Errorf("its caller reads %s", a.Calls[0].Arguments)
|
|
}
|
|
})
|
|
t.Run("a second call of the act takes the same ask, asked anew after the first runs", func(t *testing.T) {
|
|
w := setup()
|
|
_, h1, _ := hold(t, w, call("n1.a", `{}`), true)
|
|
now = now.Add(time.Second)
|
|
_, h2, _ := hold(t, w, call("n1.b", `{}`), true)
|
|
if h1.Asked != h2.Asked || h1.Call == h2.Call {
|
|
t.Fatalf("asks %s and %s", h1.Asked, h2.Asked)
|
|
}
|
|
w.router.choose(h1.Asked, OptionApprove, now)
|
|
w.g.Decided(h1.Asked)
|
|
if *w.ran != 1 || ended(w, "n1.a", h1.Call).Outcome != OutcomeRan || ended(w, "n1.b", h2.Call).Outcome != "" {
|
|
t.Fatalf("ran %d; ends %v", *w.ran, w.said)
|
|
}
|
|
e, _ := w.state.Get("call." + h2.Call)
|
|
var second WaitingCall
|
|
_ = json.Unmarshal(e.Value, &second)
|
|
if second.Ask == h1.Asked || len(w.router.asks) != 2 {
|
|
t.Errorf("the second call waits on %s (asks %d)", second.Ask, len(w.router.asks))
|
|
}
|
|
})
|
|
t.Run("a decline runs nothing; a call within 10 minutes ends declined unasked", func(t *testing.T) {
|
|
w := setup()
|
|
_, h, _ := hold(t, w, call("n1.a", `{}`), true)
|
|
w.router.choose(h.Asked, OptionDecline, now)
|
|
w.g.Decided(h.Asked)
|
|
if *w.ran != 0 || ended(w, "n1.a", h.Call).Outcome != OutcomeDeclined {
|
|
t.Fatalf("ran %d, end %+v", *w.ran, ended(w, "n1.a", h.Call))
|
|
}
|
|
if _, h2, err := hold(t, w, call("n1.a", `{}`), true); err == nil || h2 != nil || !strings.Contains(err.Error(), "declined") || len(w.router.asks) != 1 {
|
|
t.Errorf("a call within 10 minutes: %v, %d asks", err, len(w.router.asks))
|
|
}
|
|
})
|
|
t.Run("heard 31 minutes after the choice, it ends expired", func(t *testing.T) {
|
|
w := setup()
|
|
_, h, _ := hold(t, w, call("n1.a", `{}`), true)
|
|
w.router.choose(h.Asked, OptionApprove, now.Add(-31*time.Minute))
|
|
w.g.Decided(h.Asked)
|
|
if *w.ran != 0 || ended(w, "n1.a", h.Call).Outcome != OutcomeExpired {
|
|
t.Fatalf("ran %d, end %+v", *w.ran, ended(w, "n1.a", h.Call))
|
|
}
|
|
})
|
|
t.Run("a secret-shaped argument refused, nothing kept", func(t *testing.T) {
|
|
w := setup()
|
|
if _, h, err := hold(t, w, call("n1.a", `{"token":"ghp_0123456789abcdefghijklmnopqrstuvwxyzAB"}`), true); err == nil || h != nil {
|
|
t.Fatalf("held %+v, %v", h, err)
|
|
}
|
|
if keys, _ := w.state.Keys(); len(keys) != 0 || len(w.router.asks) != 0 || len(w.bounds.ttl) != 0 {
|
|
t.Errorf("kept %v, asked %d", keys, len(w.router.asks))
|
|
}
|
|
})
|
|
t.Run("a call with no wait refused with the ask, and run when made again after the Approve", func(t *testing.T) {
|
|
w := setup()
|
|
_, h, err := hold(t, w, call("n1.a", `{}`), false)
|
|
if err == nil || h != nil || len(w.router.asks) != 1 || !strings.Contains(err.Error(), w.router.asks[0].ID) {
|
|
t.Fatalf("first: %v", err)
|
|
}
|
|
w.router.choose(w.router.asks[0].ID, OptionApprove, now)
|
|
answer, _, err := hold(t, w, call("n1.a", `{}`), false)
|
|
if a, ok := answer.(Answered); err != nil || !ok || a.Answer != "wiped" || *w.ran != 1 {
|
|
t.Fatalf("again: %+v, %v", answer, err)
|
|
}
|
|
})
|
|
t.Run("a restart between the ask and the Approve runs the waiting call once", func(t *testing.T) {
|
|
w := setup()
|
|
_, h, _ := hold(t, w, call("n1.a", `{}`), true)
|
|
w.router.choose(h.Asked, OptionApprove, now)
|
|
again := newGate(w.state, w.router, w.ran, w.recs, w.said, w.after, w.finds, w.bounds)
|
|
if err := again.StartHolding(); err != nil || *w.ran != 1 || ended(w, "n1.a", h.Call).Outcome != OutcomeRan {
|
|
t.Fatalf("start: %v, ran %d", err, *w.ran)
|
|
}
|
|
_ = again.StartHolding()
|
|
if *w.ran != 1 {
|
|
t.Errorf("ran %d after a second start", *w.ran)
|
|
}
|
|
})
|
|
t.Run("past its ask's 60 minutes with no decided, gate-unanswered on its bound key's end", func(t *testing.T) {
|
|
// A-255: a bound key with the ask's 60 minutes to live in the state approvals, never a clock wait.
|
|
w := setup()
|
|
if err := w.g.StartHolding(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, h, _ := hold(t, w, call("n1.a", `{}`), true)
|
|
key := "unanswered." + h.Call
|
|
if ttl := w.bounds.ttl[key]; ttl != AskLasts {
|
|
t.Fatalf("bound %s kept for %s (%v)", key, ttl, w.bounds.ttl)
|
|
}
|
|
if w.finds.raised[KindGateUnanswered+" "+h.Call] {
|
|
t.Fatal("raised before its bound ended")
|
|
}
|
|
w.bounds.expire(key)
|
|
if !w.finds.raised[KindGateUnanswered+" "+h.Call] {
|
|
t.Errorf("not raised on its bound's end: %v", w.finds.raised)
|
|
}
|
|
w.router.choose(h.Asked, OptionDecline, now)
|
|
w.g.Decided(h.Asked)
|
|
if w.finds.raised[KindGateUnanswered+" "+h.Call] {
|
|
t.Error("not cleared once its end is said")
|
|
}
|
|
})
|
|
t.Run("a call whose bound ended while the holder was away is judged at its start", func(t *testing.T) {
|
|
w := setup()
|
|
_, h, _ := hold(t, w, call("n1.a", `{}`), true)
|
|
w.bounds.watched = nil
|
|
w.bounds.expire("unanswered." + h.Call) // the bus ended it with nobody watching
|
|
now = now.Add(AskLasts + time.Minute)
|
|
defer func() { now = now.Add(-AskLasts - time.Minute) }()
|
|
again := newGate(w.state, w.router, w.ran, w.recs, w.said, w.after, w.finds, w.bounds)
|
|
if err := again.StartHolding(); err != nil || !w.finds.raised[KindGateUnanswered+" "+h.Call] {
|
|
t.Errorf("start: %v, raised %v", err, w.finds.raised)
|
|
}
|
|
})
|
|
t.Run("two instances of the holder raise one ask and run once", func(t *testing.T) {
|
|
// A-255: a process holder's act names no node.
|
|
w := setup()
|
|
other := New(Deps{Node: "n2", Runner: "holder", Holding: true, Build: "fp1", State: w.state, Channel: w.router,
|
|
Findings: w.finds, Bounds: w.bounds, Now: func() time.Time { return now }, Go: func(f func()) { f() },
|
|
Say: func(subject string, body any, id string) error { w.said[subject] = body.(GatedEnded); return nil }})
|
|
other.Serves("wiper", "wipe", func(string, json.RawMessage) (any, error) { *w.ran++; return "wiped", nil })
|
|
_, h1, _ := hold(t, w, call("n1.a", `{}`), true)
|
|
_, h2, err := other.Hold(call("n2.b", `{}`), true, nil)
|
|
if err != nil || h2 == nil || h2.Asked != h1.Asked || len(w.router.asks) != 1 {
|
|
t.Fatalf("second instance: %+v, %v; asks %d", h2, err, len(w.router.asks))
|
|
}
|
|
if strings.Contains(w.router.asks[0].About, "n1") {
|
|
t.Errorf("the ask binds %s", w.router.asks[0].About)
|
|
}
|
|
var first WaitingCall
|
|
e, _ := w.state.Get("call." + h1.Call)
|
|
_ = json.Unmarshal(e.Value, &first)
|
|
if _, ok := first.Act["node"]; ok {
|
|
t.Errorf("the act names a node: %v", first.Act)
|
|
}
|
|
w.router.choose(h1.Asked, OptionApprove, now)
|
|
w.g.Decided(h1.Asked)
|
|
other.Decided(h1.Asked)
|
|
if *w.ran != 1 {
|
|
t.Errorf("ran %d", *w.ran)
|
|
}
|
|
})
|
|
t.Run("a state that cannot be read refuses every marked call", func(t *testing.T) {
|
|
w := setup()
|
|
w.state.fail = fmt.Errorf("the bus is away")
|
|
if _, h, err := hold(t, w, call("n1.a", `{}`), true); err == nil || h != nil || *w.ran != 0 {
|
|
t.Fatalf("held %+v, %v", h, err)
|
|
}
|
|
})
|
|
}
|