From 8cb13d10338e35f5efe71a75bd3d3579f59756a1 Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 10 Nov 2026 16:39:47 +0100 Subject: [PATCH] O-6: C-S8: stateobjects.Tools, the four tools that read and drop a module's state objects and sent objects, each drop with by and why as a hand act first (milestone 47/G, issue 1966, ADR 0666 section 25) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds: design 47 G §60, §84, §258 Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ --- go/stateobjects/fakes_test.go | 8 +- go/stateobjects/tools.go | 258 ++++++++++++++++++++++++++++++++++ go/stateobjects/tools_test.go | 110 +++++++++++++++ 3 files changed, 375 insertions(+), 1 deletion(-) create mode 100644 go/stateobjects/tools.go create mode 100644 go/stateobjects/tools_test.go diff --git a/go/stateobjects/fakes_test.go b/go/stateobjects/fakes_test.go index 60c454da..78dff60c 100644 --- a/go/stateobjects/fakes_test.go +++ b/go/stateobjects/fakes_test.go @@ -23,6 +23,7 @@ type memory struct { keys map[string]Entry objects map[string][]byte failPuts bool + modified map[string]time.Time most int } @@ -79,6 +80,11 @@ func (m *memory) Put(name string, data []byte) error { return errors.New("the store is full") } m.objects[name] = data + if m.modified != nil { + if _, ok := m.modified[name]; !ok { + m.modified[name] = time.Time{} + } + } return nil } @@ -114,7 +120,7 @@ func (o memObjects) List() ([]Object, error) { defer o.m.mu.Unlock() var out []Object for n, d := range o.m.objects { - out = append(out, Object{Name: n, Size: int64(len(d))}) + out = append(out, Object{Name: n, Size: int64(len(d)), Modified: o.m.modified[n]}) } return out, nil } diff --git a/go/stateobjects/tools.go b/go/stateobjects/tools.go new file mode 100644 index 00000000..bf1ca994 --- /dev/null +++ b/go/stateobjects/tools.go @@ -0,0 +1,258 @@ +package stateobjects + +import ( + "errors" + "fmt" + "strings" + "time" + + stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/buscalls" + "git.novox.be/novox/mesh-sdk/go/views" +) + +// SentMaxAge is how long a store of sent objects keeps one: transport, not kept data (ADR 0595 §4), so the store ages +// its objects out by itself and the drop tool is for the case where it does not. +const SentMaxAge = 7 * 24 * time.Hour + +// State is one state of a module with its objects: the state's name (its local name in the manifest), its values and the +// fill of its object store against its cap. +type State struct { + Name string + Values Values + Fill func() (buscalls.Fill, error) +} + +// Sent is a module's store of sent objects, read and dropped by the module's own tools. +type Sent struct { + Objects Objects + Fill func() (buscalls.Fill, error) + // MaxAge is the store's age (SentMaxAge when zero), which the listing compares each object's age with. + MaxAge time.Duration +} + +// Tools are the four tools of a module that keeps large values or sends large bodies (design 25 § 12.2, ADR 0666 §25), +// named for the module the caller gives: `_state_objects` and `_state_objects_drop` over the states, and +// `_sent_objects` and `_sent_objects_drop` over the store of sent objects. The two reading tools read +// everything the store holds; the two dropping tools remove only what is no longer live, each only with the person who +// asks (`by`) and why, recorded as a hand act before anything is removed, and refuse one that cannot be recorded. +func Tools(module string, states []State, sent *Sent, acts views.HandActs, now func() time.Time) []stdio.Tool { + var tools []stdio.Tool + if len(states) > 0 { + tools = append(tools, stateObjectsTool(module, states), stateDropTool(module, states, acts)) + } + if sent != nil { + tools = append(tools, sentObjectsTool(module, *sent, now), sentDropTool(module, *sent, acts, now)) + } + return tools +} + +func pick(states []State, args map[string]any) (State, error) { + name, _ := args["state"].(string) + name = strings.TrimSpace(name) + if name == "" && len(states) == 1 { + return states[0], nil + } + for _, s := range states { + if s.Name == name { + return s, nil + } + } + var names []string + for _, s := range states { + names = append(names, s.Name) + } + return State{}, fmt.Errorf("state names one of %s, not %q", strings.Join(names, ", "), name) +} + +var stateArg = map[string]any{"type": "string", "description": "the state whose object store is read: its local name in the manifest"} + +func stateObjectsTool(module string, states []State) stdio.Tool { + return stdio.Tool{ + Name: module + "_state_objects", + Description: "The objects that hold the large values of " + module + "'s states: each object, its size, the key whose value names it " + + "or key-gone when the key was deleted (such an object stays until " + module + "_state_objects_drop removes it on a person's word), " + + "and how full the object store is against its cap.", + Input: map[string]any{"type": "object", "properties": map[string]any{"state": stateArg}}, + Run: func(args map[string]any) (any, error) { + s, err := pick(states, args) + if err != nil { + return nil, err + } + kept, err := s.Values.Kept() + if err != nil { + return nil, err + } + out := map[string]any{"state": s.Name, "objects": kept} + gone := 0 + for _, k := range kept { + if k.KeyGone { + gone++ + } + } + out["key-gone"] = gone + if s.Fill != nil { + if f, err := s.Fill(); err == nil { + out["fill"] = f + } else { + out["fill-error"] = err.Error() + } + } + return out, nil + }, + } +} + +var byWhy = map[string]any{ + "by": map[string]any{"type": "string", "description": "who asks, as who@node"}, + "why": map[string]any{"type": "string", "description": "why, in plain words"}, +} + +// asked is by and why of a dropping call: both are needed, or nothing is done. +func asked(args map[string]any) (by, why string, err error) { + by, _ = args["by"].(string) + why, _ = args["why"].(string) + by, why = strings.TrimSpace(by), strings.TrimSpace(why) + if by == "" || why == "" { + return "", "", errors.New("a drop names who asks (by) and why: nothing was dropped") + } + return by, why, nil +} + +func record(acts views.HandActs, tool, what, by, why string) error { + if acts == nil { + return errors.New("no way to record a hand act is given, so nothing was dropped") + } + if err := acts.RecordHandAct(views.HandAct{What: what + " (asked by " + by + ")", Why: why, Cause: "objects-dropped", Verb: tool}); err != nil { + return fmt.Errorf("the hand act was not recorded, so nothing was dropped: %w", err) + } + return nil +} + +func stateDropTool(module string, states []State, acts views.HandActs) stdio.Tool { + name := module + "_state_objects_drop" + props := map[string]any{"state": stateArg} + for k, v := range byWhy { + props[k] = v + } + return stdio.Tool{ + Name: name, + Description: "Drop the objects of one of " + module + "'s states whose key is gone, on a person's word (ADR 0595 §4, ADR 0666 §16). " + + "An object a key names is never dropped. Recorded as a hand act first; needs by and why.", + Input: map[string]any{"type": "object", "required": []string{"by", "why"}, "properties": props}, + Run: func(args map[string]any) (any, error) { + by, why, err := asked(args) + if err != nil { + return nil, err + } + s, err := pick(states, args) + if err != nil { + return nil, err + } + if err := record(acts, name, "dropped the objects of "+module+"'s state "+s.Name+" whose key is gone", by, why); err != nil { + return nil, err + } + dropped, err := s.Values.DropKeyGone() + return map[string]any{"state": s.Name, "dropped": dropped}, err + }, + } +} + +// SentObject is one object of a store of sent objects as the listing shows it. +type SentObject struct { + Name string `json:"name"` + // Label is the verb or event the body was sent for. + Label string `json:"label"` + Size int64 `json:"size"` + Sent time.Time `json:"sent,omitzero"` + Age string `json:"age"` + PastAge bool `json:"past-age,omitempty"` +} + +func (s Sent) maxAge() time.Duration { + if s.MaxAge > 0 { + return s.MaxAge + } + return SentMaxAge +} + +func (s Sent) list(now time.Time) ([]SentObject, error) { + objects, err := s.Objects.List() + if err != nil { + return nil, err + } + out := make([]SentObject, 0, len(objects)) + for _, o := range objects { + age := now.Sub(o.Modified) + out = append(out, SentObject{Name: o.Name, Label: keyOf(o.Name), Size: o.Size, Sent: o.Modified, + Age: age.Round(time.Second).String(), PastAge: age > s.maxAge()}) + } + return out, nil +} + +func sentObjectsTool(module string, s Sent, now func() time.Time) stdio.Tool { + return stdio.Tool{ + Name: module + "_sent_objects", + Description: "The objects " + module + " sent in place of a reply or an event over the message limit: each object, its size, when it " + + "was sent, the verb or event it was sent for, its age against the store's age (" + s.maxAge().String() + ", transport and not kept data), " + + "and how full the store is against its cap.", + Run: func(map[string]any) (any, error) { + objects, err := s.list(now()) + if err != nil { + return nil, err + } + out := map[string]any{"objects": objects, "max-age": s.maxAge().String()} + if s.Fill != nil { + if f, err := s.Fill(); err == nil { + out["fill"] = f + } else { + out["fill-error"] = err.Error() + } + } + return out, nil + }, + } +} + +func sentDropTool(module string, s Sent, acts views.HandActs, now func() time.Time) stdio.Tool { + name := module + "_sent_objects_drop" + props := map[string]any{"older-than": map[string]any{"type": "string", "description": "a duration such as 24h: only objects sent longer ago are dropped"}} + for k, v := range byWhy { + props[k] = v + } + return stdio.Tool{ + Name: name, + Description: "Drop the sent objects of " + module + " older than a given age, on a person's word. Younger objects are live and stay. " + + "Recorded as a hand act first; needs by, why and older-than.", + Input: map[string]any{"type": "object", "required": []string{"by", "why", "older-than"}, "properties": props}, + Run: func(args map[string]any) (any, error) { + by, why, err := asked(args) + if err != nil { + return nil, err + } + text, _ := args["older-than"].(string) + age, err := time.ParseDuration(strings.TrimSpace(text)) + if err != nil || age <= 0 { + return nil, fmt.Errorf("older-than %q is not a duration above zero such as 24h: nothing was dropped", text) + } + if err := record(acts, name, "dropped the sent objects of "+module+" older than "+age.String(), by, why); err != nil { + return nil, err + } + objects, err := s.Objects.List() + if err != nil { + return nil, err + } + var dropped []string + for _, o := range objects { + if now().Sub(o.Modified) <= age { + continue + } + if err := s.Objects.Delete(o.Name); err != nil { + return map[string]any{"dropped": dropped}, fmt.Errorf("the object %s: %w", o.Name, err) + } + dropped = append(dropped, o.Name) + } + return map[string]any{"dropped": dropped}, nil + }, + } +} diff --git a/go/stateobjects/tools_test.go b/go/stateobjects/tools_test.go new file mode 100644 index 00000000..323edc3c --- /dev/null +++ b/go/stateobjects/tools_test.go @@ -0,0 +1,110 @@ +package stateobjects + +import ( + "errors" + "strings" + "testing" + "time" + + "git.novox.be/novox/mesh-sdk/go/kvlimit" + "git.novox.be/novox/mesh-sdk/go/views" +) + +type acts struct { + done []views.HandAct + err error +} + +func (a *acts) RecordHandAct(h views.HandAct) error { + if a.err != nil { + return a.err + } + a.done = append(a.done, h) + return nil +} + +// ADR 0666 §25: the four tools read what each store holds and drop only what is no longer live, each drop only with who +// asks and why, recorded as a hand act first. +func TestTheObjectToolsReadEverythingAndDropOnlyWhatIsNotLive(t *testing.T) { + m := newMemory() + m.modified = map[string]time.Time{} + v := m.values() + big := big(2 * kvlimit.ModuleStateValueBytes) + for _, k := range []string{"live", "gone"} { + if err := v.Put(k, big); err != nil { + t.Fatal(err) + } + } + // the two keys hold one object each (different keys): delete one key + if err := v.Delete("gone"); err != nil { + t.Fatal(err) + } + now := time.Date(2026, 11, 10, 12, 0, 0, 0, time.UTC) + rec := &acts{} + sentStore := newMemory() + sentStore.modified = map[string]time.Time{"reply.list.aa": now.Add(-48 * time.Hour), "event.done.bb": now.Add(-time.Hour)} + sentStore.objects = map[string][]byte{"reply.list.aa": []byte("1"), "event.done.bb": []byte("22")} + tools := Tools("m", []State{{Name: "views", Values: v}}, &Sent{Objects: memObjects{sentStore}}, rec, func() time.Time { return now }) + by := map[string]func(map[string]any) (any, error){} + for _, tl := range tools { + by[tl.Name] = tl.Run + } + for _, name := range []string{"m_state_objects", "m_state_objects_drop", "m_sent_objects", "m_sent_objects_drop"} { + if by[name] == nil { + t.Fatalf("no tool %s", name) + } + } + read, err := by["m_state_objects"](map[string]any{}) + if err != nil || read.(map[string]any)["key-gone"] != 1 { + t.Fatalf("the state's objects: %v, %v", read, err) + } + listed, err := by["m_sent_objects"](map[string]any{}) + if objs := listed.(map[string]any)["objects"].([]SentObject); err != nil || len(objs) != 2 { + t.Fatalf("the sent objects: %v, %v", listed, err) + } + // each drop refuses without by and why, and records nothing + for _, tc := range []struct { + tool string + args map[string]any + }{ + {"m_state_objects_drop", map[string]any{"why": "x"}}, + {"m_state_objects_drop", map[string]any{"by": "a@b"}}, + {"m_sent_objects_drop", map[string]any{"why": "x", "older-than": "24h"}}, + {"m_sent_objects_drop", map[string]any{"by": "a@b", "why": "x", "older-than": "soon"}}, + } { + if _, err := by[tc.tool](tc.args); err == nil { + t.Errorf("%s %v was accepted", tc.tool, tc.args) + } + } + if len(rec.done) != 0 { + t.Fatalf("a refused drop recorded %v", rec.done) + } + // the hand act is recorded first: one that cannot be recorded drops nothing + rec.err = errors.New("hand acts are down") + if _, err := by["m_state_objects_drop"](map[string]any{"by": "a@b", "why": "tidy"}); err == nil || !strings.Contains(err.Error(), "nothing was dropped") { + t.Fatalf("an unrecorded drop: %v", err) + } + if kept, _ := v.Kept(); len(kept) != 2 { + t.Fatalf("an unrecorded drop removed objects: %d left", len(kept)) + } + rec.err = nil + // the state drop takes only the key-gone object + out, err := by["m_state_objects_drop"](map[string]any{"by": "a@b", "why": "tidy"}) + if err != nil || len(out.(map[string]any)["dropped"].([]string)) != 1 { + t.Fatalf("the state drop: %v, %v", out, err) + } + if kept, _ := v.Kept(); len(kept) != 1 || kept[0].KeyGone { + t.Fatalf("the live object did not survive: %+v", kept) + } + // the sent drop takes only objects past the age + out, err = by["m_sent_objects_drop"](map[string]any{"by": "a@b", "why": "tidy", "older-than": "24h"}) + if err != nil || len(out.(map[string]any)["dropped"].([]string)) != 1 { + t.Fatalf("the sent drop: %v, %v", out, err) + } + if _, ok := sentStore.objects["event.done.bb"]; !ok || len(sentStore.objects) != 1 { + t.Fatalf("the young sent object did not survive: %v", sentStore.objects) + } + if len(rec.done) != 2 { + t.Fatalf("%d hand acts, want 2", len(rec.done)) + } +}