From d41d255f28adf870f605c4e098e287b168eadaec Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 12 Oct 2026 10:17:56 +0200 Subject: [PATCH] go: a module reads the values its bucket still keeps for a key, so the tracker can carry them into its log (hq ADR 0307) --- go/README.md | 2 +- go/state.go | 25 +++++++ go/state_history_test.go | 142 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 168 insertions(+), 1 deletion(-) create mode 100644 go/state_history_test.go diff --git a/go/README.md b/go/README.md index 412efb7..76ffd0f 100644 --- a/go/README.md +++ b/go/README.md @@ -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 `.` 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 diff --git a/go/state.go b/go/state.go index 7197cab..70e1b35 100644 --- a/go/state.go +++ b/go/state.go @@ -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}) diff --git a/go/state_history_test.go b/go/state_history_test.go new file mode 100644 index 0000000..33dbcd4 --- /dev/null +++ b/go/state_history_test.go @@ -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) + } +} -- 2.54.0