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:
@@ -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)
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user