issue 1858: record the acts that change no entry, and answer the action log (1858.4)

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.
This commit is contained in:
jochen
2026-10-22 19:54:28 +02:00
parent fac091756e
commit da8a6a6df4
8 changed files with 193 additions and 5 deletions
@@ -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
}
@@ -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)
@@ -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)
}
}
}
}
@@ -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 " +
@@ -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)