From da8a6a6df4aef96d9fb56aa7e5c2b1996d06c1e2 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 22 Oct 2026 19:54:28 +0200 Subject: [PATCH] issue 1858: record the acts that change no entry, and answer the action log (1858.4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR 0517 §3 to §5: record writes one line for a mail opened, a mode chosen or the page opened (the last looked list answers); every other line is its own verb's. actions answers the log newest first. --- .../cmd/mesh-mail-reader/listverbs.go | 83 ++++++++++++++++++ .../cmd/mesh-mail-reader/manifest_test.go | 2 +- .../cmd/mesh-mail-reader/record_test.go | 84 +++++++++++++++++++ .../cmd/mesh-mail-reader/tools.go | 19 +++++ .../cmd/mesh-mail-reader/tools_test.go | 2 +- modules/mesh-mail-reader/module.json | 4 +- .../mesh-mail/cmd/mesh-mail/manifest_test.go | 2 +- modules/mesh-mail/module.json | 2 +- 8 files changed, 193 insertions(+), 5 deletions(-) create mode 100644 modules/mesh-mail-reader/cmd/mesh-mail-reader/record_test.go diff --git a/modules/mesh-mail-reader/cmd/mesh-mail-reader/listverbs.go b/modules/mesh-mail-reader/cmd/mesh-mail-reader/listverbs.go index 64396cb5..df71dfec 100644 --- a/modules/mesh-mail-reader/cmd/mesh-mail-reader/listverbs.go +++ b/modules/mesh-mail-reader/cmd/mesh-mail-reader/listverbs.go @@ -251,3 +251,86 @@ func (b *Bundle) listTool(args map[string]any) (any, error) { } return out, nil } + +// MaxActions bounds what actions answers. +const MaxActions = 1000 + +// recordTool writes one line for an act that changes no entry (ADR 0517 §4, §5): opened (a mail shown in the +// reading pane, by its id), mode (the mode chosen) or page opened (the operator last looked). Every other line +// is its own verb's. +func (b *Bundle) recordTool(args map[string]any) (any, error) { + if _, err := b.checkCaller(args); err != nil { + return nil, err + } + if err := only(args, "what", "id", "mode"); err != nil { + return nil, err + } + what, _ := args["what"].(string) + if what != ActOpened && what != ActMode && what != ActPageOpened { + return nil, fmt.Errorf("%s records opened, mode or page opened; %q is none of them (each other act writes its own line)", RecordTool, what) + } + mode, err := modeArg(args) + if err != nil { + return nil, err + } + id, err := number(args, "id", 0) + if err != nil { + return nil, err + } + ctx := context.Background() + store, err := b.Store(ctx) + if err != nil { + return nil, err + } + line := ActionLine{What: what, Mode: mode, At: b.now()} + switch { + case what == ActOpened && id <= 0: + return nil, fmt.Errorf("%s: opened needs the id of the mail entry opened", RecordTool) + case what == ActOpened: + if _, ok, err := store.Entry(ctx, int64(id)); err != nil { + return nil, err + } else if !ok { + return nil, fmt.Errorf("%s: no mail entry %d is held; list answers the ids held", RecordTool, id) + } + line.Entry = int64(id) + line.Item = ItemMail + case id != 0: + return nil, fmt.Errorf("%s: %s names no mail entry", RecordTool, what) + } + if err := store.Record(ctx, line); err != nil { + return nil, err + } + return map[string]any{"recorded": what}, nil +} + +// actionsTool answers the action log, newest first (ADR 0517 §4): what, the mail id, the item, the mode and +// when — never mail text. +func (b *Bundle) actionsTool(args map[string]any) (any, error) { + if _, err := b.checkCaller(args); err != nil { + return nil, err + } + if err := only(args, "limit"); err != nil { + return nil, err + } + limit, err := number(args, "limit", 100) + if err != nil { + return nil, err + } + if limit <= 0 || limit > MaxActions { + limit = MaxActions + } + ctx := context.Background() + store, err := b.Store(ctx) + if err != nil { + return nil, err + } + got, err := store.Actions(ctx, limit) + if err != nil { + return nil, err + } + out := make([]map[string]any, len(got)) + for i, a := range got { + out[i] = map[string]any{"what": a.What, "id": a.Entry, "item": a.Item, "mode": a.Mode, "at": a.At.UTC().Format(time.RFC3339)} + } + return map[string]any{"actions": out}, nil +} diff --git a/modules/mesh-mail-reader/cmd/mesh-mail-reader/manifest_test.go b/modules/mesh-mail-reader/cmd/mesh-mail-reader/manifest_test.go index 023e71a1..8344ddcc 100644 --- a/modules/mesh-mail-reader/cmd/mesh-mail-reader/manifest_test.go +++ b/modules/mesh-mail-reader/cmd/mesh-mail-reader/manifest_test.go @@ -59,7 +59,7 @@ func TestTheManifestClaimsMailReadingAndInvokesReadOnly(t *testing.T) { t.Fatalf("runs-as %q, want %q", m.RunsAs, Module) } wantClaims := []seat{ - {Name: "mail-reading", Scope: "mesh", Serves: []string{"entries", "groups", "correct", "lessons", "undo", "status", "list", "handle", "unhandle", "remind"}}, + {Name: "mail-reading", Scope: "mesh", Serves: []string{"entries", "groups", "correct", "lessons", "undo", "status", "list", "handle", "unhandle", "remind", "record", "actions"}}, } if !reflect.DeepEqual(m.Claims, wantClaims) { t.Fatalf("claims %+v, want %+v", m.Claims, wantClaims) diff --git a/modules/mesh-mail-reader/cmd/mesh-mail-reader/record_test.go b/modules/mesh-mail-reader/cmd/mesh-mail-reader/record_test.go new file mode 100644 index 00000000..634d4fbd --- /dev/null +++ b/modules/mesh-mail-reader/cmd/mesh-mail-reader/record_test.go @@ -0,0 +1,84 @@ +package main + +// Issue 1858 (novox/hq ADR 0517 §3 to §5): record writes one line for an act that changes no entry — a mail +// opened, a mode chosen, the page opened — and actions answers the log, newest first. + +import ( + "strings" + "testing" + "time" +) + +// 1858/AC4: record writes exactly one line per act; a refused one writes none. +func TestRecordWritesOneLine(t *testing.T) { + store := newMemStore() + id := entryWithItems(t, store) + now := brusselsAt(2026, 10, 22, 9, 0) + b := listBundle(store, &now) + for i, tc := range []struct { + args map[string]any + want ActionLine + }{ + {map[string]any{"what": "opened", "id": float64(id), "mode": "work"}, ActionLine{What: ActOpened, Entry: id, Item: ItemMail, Mode: "work"}}, + {map[string]any{"what": "mode", "mode": "personal"}, ActionLine{What: ActMode, Mode: "personal"}}, + {map[string]any{"what": "page opened", "mode": "everything"}, ActionLine{What: ActPageOpened, Mode: "everything"}}, + } { + now = now.Add(time.Minute) + if _, err := callTool(t, b, RecordTool, tc.args); err != nil { + t.Fatal(err) + } + got := lines(t, store) + if len(got) != i+1 { + t.Fatalf("after %v: %d lines, want %d", tc.args, len(got), i+1) + } + tc.want.ID, tc.want.At = got[0].ID, now + if got[0] != tc.want { + t.Fatalf("record %v wrote %+v, want %+v", tc.args, got[0], tc.want) + } + } + for _, args := range []map[string]any{ + {"what": "handled", "id": float64(id)}, // handle's own line, never record's + {"what": "opened"}, // opened names its mail + {"what": "opened", "id": float64(id + 99)}, // a mail entry not held + {"what": "page opened", "mode": "holiday"}, // not a mode + {"what": "page opened", "id": float64(id)}, // the page names no mail + } { + if _, err := callTool(t, b, RecordTool, args); err == nil { + t.Fatalf("record %v: no error", args) + } + } + if got := lines(t, store); len(got) != 3 { + t.Fatalf("%d lines after refused records, want still 3", len(got)) + } +} + +// 1858/AC4: actions answers the log newest first, each line what, mail id, item, mode and when. +func TestActionsAnswersTheLogNewestFirst(t *testing.T) { + store := newMemStore() + id := entryWithItems(t, store) + now := brusselsAt(2026, 10, 22, 9, 0) + b := listBundle(store, &now) + if _, err := callTool(t, b, RecordTool, map[string]any{"what": "page opened", "mode": "work"}); err != nil { + t.Fatal(err) + } + now = now.Add(time.Minute) + if _, err := callTool(t, b, HandleTool, map[string]any{"id": float64(id), "item": "task 1", "mode": "work"}); err != nil { + t.Fatal(err) + } + out, err := callTool(t, b, ActionsTool, map[string]any{"limit": float64(10)}) + if err != nil { + t.Fatal(err) + } + got := out.(map[string]any)["actions"].([]map[string]any) + if len(got) != 2 || got[0]["what"] != "handled" || got[0]["id"] != id || got[0]["item"] != "task 1" || + got[0]["mode"] != "work" || got[0]["at"] != now.UTC().Format(time.RFC3339) || got[1]["what"] != "page opened" { + t.Fatalf("actions = %v, want handled task 1 then page opened", got) + } + for _, line := range got { + for k := range line { + if !strings.Contains(" what id item mode at ", " "+k+" ") { + t.Fatalf("a line answers %q, which is none of what, id, item, mode, at", k) + } + } + } +} diff --git a/modules/mesh-mail-reader/cmd/mesh-mail-reader/tools.go b/modules/mesh-mail-reader/cmd/mesh-mail-reader/tools.go index cdacccde..8ca9061b 100644 --- a/modules/mesh-mail-reader/cmd/mesh-mail-reader/tools.go +++ b/modules/mesh-mail-reader/cmd/mesh-mail-reader/tools.go @@ -228,6 +228,25 @@ func (b *Bundle) Tools() []stdio.Tool { "names.", Input: itemActSchema(), Run: b.remindTool}, + {Name: RecordTool, + Description: "Record an act on the mail page that changes no entry (novox/hq ADR 0517 §3 to §5): opened (a " + + "mail shown in the reading pane, by its id), mode (the mode chosen) or page opened (the operator " + + "looked); one line in the action log, never mail text. Answered only to a caller the setting callers " + + "names.", + Input: schema([]string{"what"}, map[string]any{ + "what": text("opened, mode or page opened"), + "id": integer("the mail entry opened (opened only)"), + "mode": text("the mode the page is in, or chose: work, personal or everything (when left out)"), + }), + Run: b.recordTool}, + {Name: ActionsTool, + Description: "The action log, newest first (novox/hq ADR 0517 §3, §4): each line what was done, the mail " + + "id, the item, the mode and when — never mail text; kept 365 days. Answered only to a caller the " + + "setting callers names. Read-only.", + Input: schema(nil, map[string]any{ + "limit": integer("how many lines, at most 1000; 100 when left out"), + }), + Run: b.actionsTool}, {Name: HealthTool, Description: "How mesh-mail-reader is (novox/hq ADR 0459 §12): unhealthy once a batch judgement is " + "refused, times out or answers out of shape, naming which and since when; healthy once one " + diff --git a/modules/mesh-mail-reader/cmd/mesh-mail-reader/tools_test.go b/modules/mesh-mail-reader/cmd/mesh-mail-reader/tools_test.go index ca7f9fc5..c05c5401 100644 --- a/modules/mesh-mail-reader/cmd/mesh-mail-reader/tools_test.go +++ b/modules/mesh-mail-reader/cmd/mesh-mail-reader/tools_test.go @@ -21,7 +21,7 @@ func TestToolsListsTheSixVerbsAndHealth(t *testing.T) { } } want := []string{ - "mail-reading.entries", "mail-reading.groups", "mail-reading.correct", "mail-reading.lessons", "mail-reading.undo", "mail-reading.status", "mail-reading.list", "mail-reading.handle", "mail-reading.unhandle", "mail-reading.remind", "mail_reader_health", + "mail-reading.entries", "mail-reading.groups", "mail-reading.correct", "mail-reading.lessons", "mail-reading.undo", "mail-reading.status", "mail-reading.list", "mail-reading.handle", "mail-reading.unhandle", "mail-reading.remind", "mail-reading.record", "mail-reading.actions", "mail_reader_health", } if len(names) != len(want) { t.Fatalf("Tools() = %v, want %v", names, want) diff --git a/modules/mesh-mail-reader/module.json b/modules/mesh-mail-reader/module.json index 11190aa1..bcc3207a 100644 --- a/modules/mesh-mail-reader/module.json +++ b/modules/mesh-mail-reader/module.json @@ -6,7 +6,7 @@ { "name": "mail-reading", "scope": "mesh", - "serves": ["entries", "groups", "correct", "lessons", "undo", "status", "list", "handle", "unhandle", "remind"] + "serves": ["entries", "groups", "correct", "lessons", "undo", "status", "list", "handle", "unhandle", "remind", "record", "actions"] } ], "consumes": [ @@ -52,6 +52,8 @@ "mail-reading.handle", "mail-reading.unhandle", "mail-reading.remind", + "mail-reading.record", + "mail-reading.actions", "mail_reader_health" ], "runtime-health": { diff --git a/modules/mesh-mail/cmd/mesh-mail/manifest_test.go b/modules/mesh-mail/cmd/mesh-mail/manifest_test.go index acdc9c56..e6d430d8 100644 --- a/modules/mesh-mail/cmd/mesh-mail/manifest_test.go +++ b/modules/mesh-mail/cmd/mesh-mail/manifest_test.go @@ -148,7 +148,7 @@ func TestTheManifestSaysWhatTheCodeDoes(t *testing.T) { {Name: "mail-store", Scope: "mesh", Serves: []string{"accounts", "search", "read", "send", "send-status"}}, {Name: "mail-webclient", Scope: "mesh", Serves: []string{"open", "status"}}, {Name: "mail-notifier", Scope: "node", Serves: []string{"status"}, Needs: []string{"node-notifier"}}, - {Name: "mail-reading", Scope: "mesh", Serves: []string{"entries", "groups", "correct", "lessons", "undo", "status", "list", "handle", "unhandle", "remind"}}, + {Name: "mail-reading", Scope: "mesh", Serves: []string{"entries", "groups", "correct", "lessons", "undo", "status", "list", "handle", "unhandle", "remind", "record", "actions"}}, } if !reflect.DeepEqual(m.Seats, wantSeats) { t.Fatalf("seats %+v, want %+v", m.Seats, wantSeats) diff --git a/modules/mesh-mail/module.json b/modules/mesh-mail/module.json index fd5e23fe..0fd8a893 100644 --- a/modules/mesh-mail/module.json +++ b/modules/mesh-mail/module.json @@ -22,7 +22,7 @@ { "name": "mail-reading", "scope": "mesh", - "serves": ["entries", "groups", "correct", "lessons", "undo", "status", "list", "handle", "unhandle", "remind"] + "serves": ["entries", "groups", "correct", "lessons", "undo", "status", "list", "handle", "unhandle", "remind", "record", "actions"] } ], "claims": [