Merge pull request 'go: a module reads the values its bucket still keeps for a key (ADR 0307, issue 502)' (#22) from adr-0307-state-history into main
This commit was merged in pull request #22.
This commit is contained in:
+1
-1
@@ -3,7 +3,7 @@
|
||||
`stdio.Serve("", tools)` speaks MCP over stdio to the node's tool runtime, as the module the runtime
|
||||
names (novox/hq ADR 0188, ADR 0193). A `Tool` is a name, a description, an argument schema and a
|
||||
function; calls run concurrently. `stdio.CallerOf(args)` inside a tool is who called it: the bus user the runtime took from the subject the call arrived on, named in the call's `_meta`, never read from the arguments — or `stdio.Unproven` when the runtime named none (novox/hq issue 365). A tool named `<seat>.<verb>` is the seat's implementation.
|
||||
`stdio.Emit(type, body)` publishes an event as the module, `stdio.Subscribe(pattern, handler)` receives the module's events (taken when the handler returns nil, offered again when it returns an error — the one rule is in the repository README, "Taking an event") and `stdio.Ask(key, body)` calls a tool, and `stdio.State(name)` gets, puts, deletes, lists and watches the module's declared state, and `stdio.Log(name)` appends to, reads, and measures a log of operations the module declared — all through the runtime (novox/hq ADR 0193, 0198, 0201, 0297). Standard library only.
|
||||
`stdio.Emit(type, body)` publishes an event as the module, `stdio.Subscribe(pattern, handler)` receives the module's events (taken when the handler returns nil, offered again when it returns an error — the one rule is in the repository README, "Taking an event") and `stdio.Ask(key, body)` calls a tool, and `stdio.State(name)` gets, puts, deletes, lists, watches and reads the kept history of the module's declared state, and `stdio.Log(name)` appends to, reads, and measures a log of operations the module declared — all through the runtime (novox/hq ADR 0193, 0198, 0201, 0297, 0307). Standard library only.
|
||||
|
||||
## Installing
|
||||
|
||||
|
||||
+25
@@ -31,6 +31,16 @@ type StateChange struct {
|
||||
Current bool `json:"current"`
|
||||
}
|
||||
|
||||
// StateRevision is one value a bucket still keeps for a key: what was written, at which revision, by which
|
||||
// operation ("put" or "delete", whose value is null) and when (novox/hq ADR 0307).
|
||||
type StateRevision struct {
|
||||
Key string `json:"key"`
|
||||
Value json.RawMessage `json:"value"`
|
||||
Revision uint64 `json:"revision"`
|
||||
Op string `json:"op"`
|
||||
At time.Time `json:"at"`
|
||||
}
|
||||
|
||||
// KeptState is one state this module keeps, or reads.
|
||||
type KeptState struct{ name string }
|
||||
|
||||
@@ -58,6 +68,21 @@ func (s KeptState) Get(key string) (*StateEntry, error) {
|
||||
return &e, json.Unmarshal(raw, &e)
|
||||
}
|
||||
|
||||
// History is every value the bucket still keeps for key, oldest first — as many as the state declared it
|
||||
// keeps, deletes included — and empty for a key it holds nothing for (novox/hq ADR 0307). A read, like Get:
|
||||
// the owner's instances and its readers may ask it, and it writes nothing.
|
||||
func (s KeptState) History(key string) ([]StateRevision, error) {
|
||||
raw, err := s.ask("history", map[string]any{"key": key})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
revisions := []StateRevision{}
|
||||
if len(raw) == 0 || string(raw) == "null" {
|
||||
return revisions, nil
|
||||
}
|
||||
return revisions, json.Unmarshal(raw, &revisions)
|
||||
}
|
||||
|
||||
// Put writes a key — the owner's instances only — and returns the revision written.
|
||||
func (s KeptState) Put(key string, value any) (uint64, error) {
|
||||
raw, err := s.ask("put", map[string]any{"key": key, "value": value})
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
package stdio
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// historying serves a bundle whose one tool, "history", runs run, answering each mesh/state ask with
|
||||
// answer beside the loop, as the runtime does; it returns what the bundle asked.
|
||||
func historying(t *testing.T, answer func(message) map[string]any, run func()) []message {
|
||||
t.Helper()
|
||||
t.Setenv(ServedAs, "issues")
|
||||
inR, inW := io.Pipe()
|
||||
outR, outW := io.Pipe()
|
||||
go func() {
|
||||
_ = ServeOn("", []Tool{{Name: "history", Run: func(map[string]any) (any, error) { run(); return "done", nil }}}, inR, outW)
|
||||
}()
|
||||
var wmu, amu sync.Mutex
|
||||
var asked []message
|
||||
send := func(m map[string]any) {
|
||||
m["jsonrpc"] = "2.0"
|
||||
b, _ := json.Marshal(m)
|
||||
wmu.Lock()
|
||||
defer wmu.Unlock()
|
||||
_, _ = inW.Write(append(b, '\n'))
|
||||
}
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
lines := bufio.NewScanner(outR)
|
||||
for lines.Scan() {
|
||||
var m message
|
||||
if json.Unmarshal(lines.Bytes(), &m) != nil {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(m.Method, "mesh/state.") {
|
||||
amu.Lock()
|
||||
asked = append(asked, m)
|
||||
amu.Unlock()
|
||||
a := answer(m)
|
||||
a["id"] = m.ID
|
||||
send(a)
|
||||
continue
|
||||
}
|
||||
if m.Method == "" {
|
||||
close(done)
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
send(map[string]any{"id": 1, "method": "tools/call", "params": map[string]any{"name": "history"}})
|
||||
t.Cleanup(func() { _ = inW.Close() })
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("the bundle did not answer")
|
||||
}
|
||||
amu.Lock()
|
||||
defer amu.Unlock()
|
||||
return asked
|
||||
}
|
||||
|
||||
// History asks mesh/state.history with the state and the key alone, and hands back every kept value oldest
|
||||
// first, a delete with its null value; nothing kept is an empty history; a refusal is the runtime's error
|
||||
// (novox/hq ADR 0307).
|
||||
func TestHistoryIsEveryKeptValueOldestFirst(t *testing.T) {
|
||||
kept := []map[string]any{
|
||||
{"key": "401", "value": map[string]any{"status": "open"}, "revision": 4, "op": "put", "at": "2026-10-10T19:46:00Z"},
|
||||
{"key": "401", "value": map[string]any{"status": "fixing"}, "revision": 9, "op": "put", "at": "2026-10-11T08:01:02Z"},
|
||||
{"key": "401", "value": nil, "revision": 12, "op": "delete", "at": "2026-10-11T09:30:00+02:00"},
|
||||
}
|
||||
var got, none, null []StateRevision
|
||||
var refused error
|
||||
asked := historying(t, func(m message) map[string]any {
|
||||
var p map[string]any
|
||||
_ = json.Unmarshal(m.Params, &p)
|
||||
switch {
|
||||
case p["state"] != "issues":
|
||||
return map[string]any{"error": map[string]any{"code": -32000, "message": "issues was issued no state " + p["state"].(string)}}
|
||||
case p["key"] == "401":
|
||||
return map[string]any{"result": kept}
|
||||
case p["key"] == "402":
|
||||
return map[string]any{"result": nil}
|
||||
}
|
||||
return map[string]any{"result": []any{}}
|
||||
}, func() {
|
||||
var err error
|
||||
if got, err = State("issues").History("401"); err != nil {
|
||||
t.Errorf("history of 401: %v", err)
|
||||
}
|
||||
if none, err = State("issues").History("999"); err != nil {
|
||||
t.Errorf("history of 999: %v", err)
|
||||
}
|
||||
if null, err = State("issues").History("402"); err != nil {
|
||||
t.Errorf("history of 402: %v", err)
|
||||
}
|
||||
var h []StateRevision
|
||||
h, refused = State("elsewhere.issues").History("401")
|
||||
if h != nil {
|
||||
t.Errorf("a refused history handed back %v", h)
|
||||
}
|
||||
})
|
||||
|
||||
if len(asked) != 4 {
|
||||
t.Fatalf("asked %d times, not 4", len(asked))
|
||||
}
|
||||
var p map[string]any
|
||||
_ = json.Unmarshal(asked[0].Params, &p)
|
||||
if asked[0].Method != "mesh/state.history" || p["state"] != "issues" || p["key"] != "401" || len(p) != 2 {
|
||||
t.Fatalf("asked %s %v", asked[0].Method, p)
|
||||
}
|
||||
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("history of 401 is %d values, not 3: %+v", len(got), got)
|
||||
}
|
||||
want := []struct {
|
||||
rev uint64
|
||||
op string
|
||||
value string
|
||||
at time.Time
|
||||
}{
|
||||
{4, "put", `{"status":"open"}`, time.Date(2026, 10, 10, 19, 46, 0, 0, time.UTC)},
|
||||
{9, "put", `{"status":"fixing"}`, time.Date(2026, 10, 11, 8, 1, 2, 0, time.UTC)},
|
||||
{12, "delete", `null`, time.Date(2026, 10, 11, 7, 30, 0, 0, time.UTC)},
|
||||
}
|
||||
for i, w := range want {
|
||||
r := got[i]
|
||||
if r.Key != "401" || r.Revision != w.rev || r.Op != w.op || string(r.Value) != w.value || !r.At.Equal(w.at) {
|
||||
t.Fatalf("value %d is %+v, not %+v", i, r, w)
|
||||
}
|
||||
}
|
||||
if none == nil || len(none) != 0 || null == nil || len(null) != 0 {
|
||||
t.Fatalf("nothing kept read as %v and %v, not an empty history", none, null)
|
||||
}
|
||||
if refused == nil || !strings.Contains(refused.Error(), "issued no state elsewhere.issues") {
|
||||
t.Fatalf("a state not issued answered %v", refused)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user